Fix fallocate result am: 23cdfd4634 am: fb35c2911d
Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/providers/MediaProvider/+/14841299
Change-Id: Ie63ada7601870566f0b3f641eac369045bcac79e
diff --git a/Android.bp b/Android.bp
index 3cad281..3f219dc 100644
--- a/Android.bp
+++ b/Android.bp
@@ -23,6 +23,10 @@
static_libs: [
"androidx.appcompat_appcompat",
"androidx.core_core",
+ "androidx.legacy_legacy-support-core-ui",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.recyclerview_recyclerview",
+ "com.google.android.material_material",
"guava",
"modules-utils-build",
],
@@ -59,7 +63,6 @@
sdk_version: "module_current",
min_sdk_version: "30",
- target_sdk_version: "30",
certificate: "media",
privileged: true,
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index d57a013..5f0194f 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -134,5 +134,17 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
+
+ <activity
+ android:name="com.android.providers.media.photopicker.PhotoPickerActivity"
+ android:theme="@style/PickerDefaultTheme"
+ android:exported="true"
+ android:excludeFromRecents="true"
+ android:priority="100" >
+ <intent-filter>
+ <action android:name="android.provider.action.PICK_IMAGES" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
</application>
</manifest>
diff --git a/apex/framework/api/current.txt b/apex/framework/api/current.txt
index 2e6312c..faecbc8 100644
--- a/apex/framework/api/current.txt
+++ b/apex/framework/api/current.txt
@@ -26,6 +26,7 @@
method @NonNull public static android.net.Uri setRequireOriginal(@NonNull android.net.Uri);
field public static final String ACTION_IMAGE_CAPTURE = "android.media.action.IMAGE_CAPTURE";
field public static final String ACTION_IMAGE_CAPTURE_SECURE = "android.media.action.IMAGE_CAPTURE_SECURE";
+ field public static final String ACTION_PICK_IMAGES = "android.provider.action.PICK_IMAGES";
field public static final String ACTION_REVIEW = "android.provider.action.REVIEW";
field public static final String ACTION_REVIEW_SECURE = "android.provider.action.REVIEW_SECURE";
field public static final String ACTION_VIDEO_CAPTURE = "android.media.action.VIDEO_CAPTURE";
@@ -46,6 +47,8 @@
field public static final String EXTRA_MEDIA_RADIO_CHANNEL = "android.intent.extra.radio_channel";
field public static final String EXTRA_MEDIA_TITLE = "android.intent.extra.title";
field public static final String EXTRA_OUTPUT = "output";
+ field public static final String EXTRA_PICK_IMAGES_MAX = "android.provider.extra.PICK_IMAGES_MAX";
+ field public static final String EXTRA_PICK_IMAGES_MIN = "android.provider.extra.PICK_IMAGES_MIN";
field public static final String EXTRA_SCREEN_ORIENTATION = "android.intent.extra.screenOrientation";
field public static final String EXTRA_SHOW_ACTION_ICONS = "android.intent.extra.showActionIcons";
field public static final String EXTRA_SIZE_LIMIT = "android.intent.extra.sizeLimit";
diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java
index c39d334..150a910 100644
--- a/apex/framework/java/android/provider/MediaStore.java
+++ b/apex/framework/java/android/provider/MediaStore.java
@@ -638,6 +638,57 @@
public final static String EXTRA_OUTPUT = "output";
/**
+ * Activity Action: Allow the user to select images or videos provided by
+ * system and return it. This is different than {@link Intent#ACTION_PICK}
+ * and {@link Intent#ACTION_GET_CONTENT} in that
+ * <ul>
+ * <li> the data for this action is provided by system
+ * <li> this action is only used for picking images and videos
+ * <li> caller gets read access to user picked items even without storage
+ * permissions
+ * </ul>
+ * <p>
+ * Callers can optionally specify MIME type (such as {@code image/*} or
+ * {@literal *}/*), resulting in a range of content selection that the
+ * caller is interested in. The optional MIME type can be requested with
+ * {@link Intent#setType(String)}.
+ * <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
+ * using {@link MediaStore#EXTRA_PICK_IMAGES_MIN} and
+ * {@link MediaStore#EXTRA_PICK_IMAGES_MAX}.
+ * When there is no constraint on number of items, all of the user selected
+ * items are returned. TODO(b/185782624): Add constraint on maximum items
+ * picker can return.
+ * <p>
+ * Output: MediaStore content URI(s) of the item(s) that was picked.
+ */
+ @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
+ public static final String ACTION_PICK_IMAGES = "android.provider.action.PICK_IMAGES";
+
+ /**
+ * The name of an optional intent-extra used to constrain minimum number of
+ * items that should 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 less than
+ * or equal to {@link MediaStore#EXTRA_PICK_IMAGES_MAX}, the value is
+ * ignored otherwise.
+ */
+ public final static String EXTRA_PICK_IMAGES_MIN = "android.provider.extra.PICK_IMAGES_MIN";
+
+ /**
+ * 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 {@link MediaStore#EXTRA_PICK_IMAGES_MAX}, the value
+ * is ignored otherwise.
+ */
+ public final static String EXTRA_PICK_IMAGES_MAX = "android.provider.extra.PICK_IMAGES_MAX";
+
+ /**
* Specify that the caller wants to receive the original media format without transcoding.
*
* <b>Caution: using this flag can cause app
diff --git a/jni/MediaProviderWrapper.cpp b/jni/MediaProviderWrapper.cpp
index 9f8a759..9e83a58 100644
--- a/jni/MediaProviderWrapper.cpp
+++ b/jni/MediaProviderWrapper.cpp
@@ -41,6 +41,14 @@
constexpr uid_t ROOT_UID = 0;
constexpr uid_t SHELL_UID = 2000;
+// These need to stay in sync with MediaProvider.java's DIRECTORY_ACCESS_FOR_* constants.
+enum DirectoryAccessRequestType {
+ kReadDirectoryRequest = 1,
+ kWriteDirectoryRequest = 2,
+ kCreateDirectoryRequest = 3,
+ kDeleteDirectoryRequest = 4,
+};
+
/** Private helper functions **/
inline bool shouldBypassMediaProvider(uid_t uid) {
@@ -91,25 +99,12 @@
return res;
}
-int isMkdirOrRmdirAllowedInternal(JNIEnv* env, jobject media_provider_object,
- jmethodID mid_is_mkdir_or_rmdir_allowed, const string& path,
- uid_t uid, bool forCreate) {
+int isDirAccessAllowedInternal(JNIEnv* env, jobject media_provider_object,
+ jmethodID mid_is_diraccess_allowed, const string& path, uid_t uid,
+ int accessType) {
ScopedLocalRef<jstring> j_path(env, env->NewStringUTF(path.c_str()));
- int res = env->CallIntMethod(media_provider_object, mid_is_mkdir_or_rmdir_allowed, j_path.get(),
- uid, forCreate);
-
- if (CheckForJniException(env)) {
- return EFAULT;
- }
- return res;
-}
-
-int isOpendirAllowedInternal(JNIEnv* env, jobject media_provider_object,
- jmethodID mid_is_opendir_allowed, const string& path, uid_t uid,
- bool forWrite) {
- ScopedLocalRef<jstring> j_path(env, env->NewStringUTF(path.c_str()));
- int res = env->CallIntMethod(media_provider_object, mid_is_opendir_allowed, j_path.get(), uid,
- forWrite);
+ int res = env->CallIntMethod(media_provider_object, mid_is_diraccess_allowed, j_path.get(), uid,
+ accessType);
if (CheckForJniException(env)) {
return EFAULT;
@@ -235,10 +230,8 @@
"(Ljava/lang/String;Ljava/lang/String;IIIZZZ)Lcom/android/"
"providers/media/FileOpenResult;",
/*is_static*/ false);
- mid_is_mkdir_or_rmdir_allowed_ = CacheMethod(env, "isDirectoryCreationOrDeletionAllowed",
- "(Ljava/lang/String;IZ)I", /*is_static*/ false);
- mid_is_opendir_allowed_ = CacheMethod(env, "isOpendirAllowed", "(Ljava/lang/String;IZ)I",
- /*is_static*/ false);
+ mid_is_diraccess_allowed_ = CacheMethod(env, "isDirAccessAllowed", "(Ljava/lang/String;II)I",
+ /*is_static*/ false);
mid_get_files_in_dir_ =
CacheMethod(env, "getFilesInDirectory", "(Ljava/lang/String;I)[Ljava/lang/String;",
/*is_static*/ false);
@@ -371,9 +364,8 @@
}
JNIEnv* env = MaybeAttachCurrentThread();
- return isMkdirOrRmdirAllowedInternal(env, media_provider_object_,
- mid_is_mkdir_or_rmdir_allowed_, path, uid,
- /*forCreate*/ true);
+ return isDirAccessAllowedInternal(env, media_provider_object_, mid_is_diraccess_allowed_, path,
+ uid, kCreateDirectoryRequest);
}
int MediaProviderWrapper::IsDeletingDirAllowed(const string& path, uid_t uid) {
@@ -382,9 +374,8 @@
}
JNIEnv* env = MaybeAttachCurrentThread();
- return isMkdirOrRmdirAllowedInternal(env, media_provider_object_,
- mid_is_mkdir_or_rmdir_allowed_, path, uid,
- /*forCreate*/ false);
+ return isDirAccessAllowedInternal(env, media_provider_object_, mid_is_diraccess_allowed_, path,
+ uid, kDeleteDirectoryRequest);
}
std::vector<std::shared_ptr<DirectoryEntry>> MediaProviderWrapper::GetDirectoryEntries(
@@ -417,8 +408,9 @@
}
JNIEnv* env = MaybeAttachCurrentThread();
- return isOpendirAllowedInternal(env, media_provider_object_, mid_is_opendir_allowed_, path, uid,
- forWrite);
+ return isDirAccessAllowedInternal(env, media_provider_object_, mid_is_diraccess_allowed_, path,
+ uid,
+ forWrite ? kWriteDirectoryRequest : kReadDirectoryRequest);
}
bool MediaProviderWrapper::isUidAllowedAccessToDataOrObbPath(uid_t uid, const string& path) {
diff --git a/jni/MediaProviderWrapper.h b/jni/MediaProviderWrapper.h
index 2ad1769..ebcb099 100644
--- a/jni/MediaProviderWrapper.h
+++ b/jni/MediaProviderWrapper.h
@@ -259,8 +259,7 @@
jmethodID mid_delete_file_;
jmethodID mid_on_file_open_;
jmethodID mid_scan_file_;
- jmethodID mid_is_mkdir_or_rmdir_allowed_;
- jmethodID mid_is_opendir_allowed_;
+ jmethodID mid_is_diraccess_allowed_;
jmethodID mid_get_files_in_dir_;
jmethodID mid_rename_;
jmethodID mid_is_uid_allowed_access_to_data_or_obb_path_;
diff --git a/res/drawable/ic_check_circle_filled.xml b/res/drawable/ic_check_circle_filled.xml
new file mode 100644
index 0000000..5e35ba9
--- /dev/null
+++ b/res/drawable/ic_check_circle_filled.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?android:attr/colorAccent"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM10,17l-4,-4l1.4,-1.4l2.6,2.6l6.6,-6.6L18,9L10,17z"/>
+</vector>
diff --git a/res/drawable/ic_gif.xml b/res/drawable/ic_gif.xml
new file mode 100644
index 0000000..f0d1c98
--- /dev/null
+++ b/res/drawable/ic_gif.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M11.5,9L13,9v6h-1.5L11.5,9zM9,9L6,9c-0.6,0 -1,0.5 -1,1v4c0,0.5 0.4,1 1,1h3c0.6,0 1,-0.5 1,-1v-2L8.5,12v1.5h-2v-3L10,10.5L10,10c0,-0.5 -0.4,-1 -1,-1zM19,10.5L19,9h-4.5v6L16,15v-2h2v-1.5h-2v-1h3z"/>
+</vector>
diff --git a/res/drawable/ic_play_circle_filled.xml b/res/drawable/ic_play_circle_filled.xml
new file mode 100644
index 0000000..e7509a2
--- /dev/null
+++ b/res/drawable/ic_play_circle_filled.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="18dp"
+ android:height="18dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,16.5v-9l6,4.5 -6,4.5z"/>
+</vector>
diff --git a/res/drawable/ic_radio_button_unchecked.xml b/res/drawable/ic_radio_button_unchecked.xml
new file mode 100644
index 0000000..622010e
--- /dev/null
+++ b/res/drawable/ic_radio_button_unchecked.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
+</vector>
diff --git a/res/drawable/ic_view_selected.xml b/res/drawable/ic_view_selected.xml
new file mode 100644
index 0000000..3781aca
--- /dev/null
+++ b/res/drawable/ic_view_selected.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="18dp"
+ android:height="18dp"
+ android:viewportHeight="15"
+ android:viewportWidth="15">
+ <path
+ android:fillColor="?android:attr/colorAccent"
+ android:pathData="M5 0.5H14C14.825 0.5 15.5 1.175 15.5 2V11C15.5 11.825 14.825 12.5 14 12.5H5C4.175 12.5 3.5 11.825 3.5 11V2C3.5 1.175 4.175 0.5 5 0.5ZM14 11V2H5V11H14ZM0.5 3.5V14C0.5 14.825 1.175 15.5 2 15.5H12.5V14H2V3.5H0.5ZM8.8775 9.485L10.7525 7.25L13.25 10.25H5.75L7.625 7.85L8.8775 9.485Z" />
+</vector>
\ No newline at end of file
diff --git a/res/drawable/picker_item_check.xml b/res/drawable/picker_item_check.xml
new file mode 100644
index 0000000..fb0ef88
--- /dev/null
+++ b/res/drawable/picker_item_check.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:state_selected="true">
+ <layer-list>
+ <item android:gravity="center"
+ android:width="18dp"
+ android:height="18dp">
+ <shape android:shape="oval">
+ <solid android:color="@color/picker_background_color"/>
+ </shape>
+ </item>
+ <item android:drawable="@drawable/ic_check_circle_filled"/>
+ </layer-list>
+ </item>
+ <item android:drawable="@drawable/ic_radio_button_unchecked"/>
+</selector>
diff --git a/res/drawable/preview_check.xml b/res/drawable/preview_check.xml
new file mode 100644
index 0000000..c523a9e
--- /dev/null
+++ b/res/drawable/preview_check.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:state_selected="true"
+ android:drawable="@drawable/ic_check_circle_filled"/>
+ <item android:drawable="@drawable/ic_radio_button_unchecked"/>
+</selector>
diff --git a/res/layout/photo_picker.xml b/res/layout/activity_photo_picker.xml
similarity index 62%
rename from res/layout/photo_picker.xml
rename to res/layout/activity_photo_picker.xml
index 073a8bd..d928bc0 100644
--- a/res/layout/photo_picker.xml
+++ b/res/layout/activity_photo_picker.xml
@@ -23,17 +23,22 @@
android:layout_height="match_parent"
tools:context=".MainActivity">
- <Button
- android:id="@+id/button"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Give up"
- />
+ <com.google.android.material.appbar.AppBarLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
- <ListView
- android:id="@+id/names_list"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
+ <androidx.appcompat.widget.Toolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ android:background="?android:attr/colorBackground"/>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"/>
</LinearLayout>
diff --git a/res/layout/fragment_photos_tab.xml b/res/layout/fragment_photos_tab.xml
new file mode 100644
index 0000000..53013ed
--- /dev/null
+++ b/res/layout/fragment_photos_tab.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+
+ android:id="@+id/photo_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:drawSelectorOnTop="true"
+ android:overScrollMode="never"/>
+
+ <FrameLayout
+ android:id="@+id/picker_bottom_bar"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/picker_bottom_bar_size"
+ android:layout_gravity="bottom"
+ android:background="@color/picker_background_color"
+ android:elevation="@dimen/picker_bottom_bar_elevation"
+ android:visibility="gone">
+
+ <Button
+ android:id="@+id/button_view_selected"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_marginHorizontal="@dimen/picker_bottom_bar_horizontal_gap"
+ android:layout_gravity="left"
+ android:paddingVertical="@dimen/picker_bottom_bar_vertical_gap"
+ android:drawableLeft="@drawable/ic_view_selected"
+ android:text="@string/picker_view_selected"
+ android:textAllCaps="false"
+ app:iconPadding="@dimen/picker_bottom_bar_vertical_gap"
+ style="?attr/borderlessButtonStyle"/>
+
+ <Button
+ android:id="@+id/button_add"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_marginHorizontal="@dimen/picker_bottom_bar_horizontal_gap"
+ android:layout_gravity="right"
+ android:paddingVertical="@dimen/picker_bottom_bar_vertical_gap"
+ android:text="@string/add"
+ android:textAllCaps="false"
+ style="?attr/materialButtonStyle"/>
+
+ </FrameLayout>
+</FrameLayout>
\ No newline at end of file
diff --git a/res/layout/fragment_preview.xml b/res/layout/fragment_preview.xml
new file mode 100644
index 0000000..bb9fa73
--- /dev/null
+++ b/res/layout/fragment_preview.xml
@@ -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.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:background="@color/preview_default_black"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:id="@+id/preview_viewPager"
+ android:layout_gravity="center"/>
+
+ <FrameLayout
+ android:layout_marginHorizontal="@dimen/preview_buttons_margin_horizontal"
+ android:layout_gravity="bottom"
+ android:layout_marginBottom="@dimen/preview_buttons_margin_bottom"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <Button
+ android:id="@+id/preview_select_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start|center_vertical"
+ android:paddingStart="@dimen/preview_deselect_padding_start"
+ android:background="@android:color/transparent"
+ android:drawableLeft="@drawable/preview_check"
+ android:drawableTint="@color/preview_default_blue"
+ android:textAllCaps="false"
+ android:text="@string/deselect"
+ android:textColor="@color/picker_default_white"
+ android:textSize="@dimen/preview_deselect_text_size" />
+
+ <Button
+ android:id="@+id/preview_add_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="end|center_vertical"
+ android:backgroundTint="@color/preview_default_blue"
+ android:text="@string/add"
+ android:textAllCaps="false"
+ android:textColor="@color/preview_default_grey"
+ android:textSize="@dimen/preview_add_text_size"/>
+ </FrameLayout>
+</FrameLayout>
diff --git a/res/layout/item_image_preview.xml b/res/layout/item_image_preview.xml
new file mode 100644
index 0000000..932ce58
--- /dev/null
+++ b/res/layout/item_image_preview.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" >
+ <ImageView
+ android:id="@+id/preview_imageView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"
+ android:scaleType="fitCenter"
+ android:contentDescription="@null"/>
+</LinearLayout>
diff --git a/res/layout/item_photo_grid.xml b/res/layout/item_photo_grid.xml
new file mode 100644
index 0000000..f9ced81
--- /dev/null
+++ b/res/layout/item_photo_grid.xml
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_margin="1.5dp"
+ android:background="@color/picker_highlight_color"
+ android:focusable="true">
+
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:elevation="0dp"
+ android:duplicateParentState="true"
+ app:cardElevation="0dp"
+ app:cardCornerRadius="0dp">
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.android.providers.media.photopicker.ui.SquareImageView
+ android:id="@+id/icon_thumbnail"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:scaleType="centerCrop"
+ android:contentDescription="@null"/>
+
+ <ImageView
+ android:id="@+id/icon_gif"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="right|top"
+ android:layout_marginEnd="@dimen/picker_item_badge_margin"
+ android:layout_marginTop="@dimen/picker_item_badge_margin"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_gif"
+ android:contentDescription="@null"/>
+
+ <LinearLayout
+ android:id="@+id/video_container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="right|top"
+ android:layout_marginEnd="@dimen/picker_item_badge_margin"
+ android:layout_marginTop="@dimen/picker_item_badge_margin"
+ android:orientation="horizontal"
+ android:contentDescription="@null">
+
+ <TextView
+ android:id="@+id/video_duration"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="@dimen/picker_item_badge_text_margin"
+ android:layout_gravity="center_vertical"
+ android:textColor="@android:color/white"
+ android:textSize="@dimen/picker_item_badge_text_size"/>
+
+ <ImageView
+ android:id="@+id/icon_video"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:scaleType="fitCenter"
+ android:src="@drawable/ic_play_circle_filled"
+ android:contentDescription="@null"/>
+ </LinearLayout>
+ </FrameLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <ImageView
+ android:id="@+id/icon_check"
+ android:layout_height="@dimen/picker_item_check_size"
+ android:layout_width="@dimen/picker_item_check_size"
+ android:layout_marginStart="@dimen/picker_item_check_margin"
+ android:layout_marginTop="@dimen/picker_item_check_margin"
+ android:src="@drawable/picker_item_check"
+ android:layout_gravity="top|left"
+ android:scaleType="fitCenter"/>
+
+</FrameLayout>
diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml
index 46d36bf..6c177f2 100644
--- a/res/values-af/strings.xml
+++ b/res/values-af/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Vee uit"</string>
<string name="allow" msgid="8885707816848569619">"Laat toe"</string>
<string name="deny" msgid="6040983710442068936">"Weier"</string>
+ <string name="add" msgid="2894574044585549298">"Voeg by"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Bekyk geselekteerde"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Laat <xliff:g id="APP_NAME_1">^1</xliff:g> toe om <xliff:g id="COUNT">^2</xliff:g> oudiolêers te wysig?</item>
<item quantity="one">Laat <xliff:g id="APP_NAME_0">^1</xliff:g> toe om hierdie oudiolêer te wysig?</item>
diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml
index fb69fe1..212134f 100644
--- a/res/values-am/strings.xml
+++ b/res/values-am/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"አጽዳ"</string>
<string name="allow" msgid="8885707816848569619">"ፍቀድ"</string>
<string name="deny" msgid="6040983710442068936">"ከልክል"</string>
+ <string name="add" msgid="2894574044585549298">"አክል"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"የተመረጡትን አሳይ"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one"><xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ኦዲዮ ፋይሎችን እንዲቀይር ይፈቀድለት?</item>
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ኦዲዮ ፋይሎችን እንዲቀይር ይፈቀድለት?</item>
diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml
index 9fef177..b9115b3 100644
--- a/res/values-ar/strings.xml
+++ b/res/values-ar/strings.xml
@@ -51,6 +51,8 @@
<string name="clear" msgid="5524638938415865915">"محو"</string>
<string name="allow" msgid="8885707816848569619">"سماح"</string>
<string name="deny" msgid="6040983710442068936">"رفض"</string>
+ <string name="add" msgid="2894574044585549298">"إضافة"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"عرض المحتوى المحدّد"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="zero">هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> ملف صوتي؟</item>
<item quantity="two">هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل ملفين صوتيين (<xliff:g id="COUNT">^2</xliff:g>)؟</item>
diff --git a/res/values-as/strings.xml b/res/values-as/strings.xml
index fbb3a35..5eda0a9 100644
--- a/res/values-as/strings.xml
+++ b/res/values-as/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"মচক"</string>
<string name="allow" msgid="8885707816848569619">"অনুমতি দিয়ক"</string>
<string name="deny" msgid="6040983710442068936">"অস্বীকাৰ কৰক"</string>
+ <string name="add" msgid="2894574044585549298">"যোগ দিয়ক"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ভিউ বাছনি কৰা হৈছে"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one"><xliff:g id="APP_NAME_1">^1</xliff:g>ক <xliff:g id="COUNT">^2</xliff:g> টা অডিঅ’ ফাইল সংশোধন কৰিবলৈ অনুমতি দিবনে?</item>
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>ক <xliff:g id="COUNT">^2</xliff:g> টা অডিঅ’ ফাইল সংশোধন কৰিবলৈ অনুমতি দিবনে?</item>
diff --git a/res/values-az/strings.xml b/res/values-az/strings.xml
index 785ada1..8f1b49c 100644
--- a/res/values-az/strings.xml
+++ b/res/values-az/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Silin"</string>
<string name="allow" msgid="8885707816848569619">"İcazə verin"</string>
<string name="deny" msgid="6040983710442068936">"Rədd edin"</string>
+ <string name="add" msgid="2894574044585549298">"Əlavə edin"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Seçilənə baxın"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> tətbiqinə <xliff:g id="COUNT">^2</xliff:g> audio fayla dəyişiklik etmək icazəsi verilsin?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> tətbiqinə bu audio fayla dəyişiklik etmək icazəsi verilsin?</item>
diff --git a/res/values-b+sr+Latn/strings.xml b/res/values-b+sr+Latn/strings.xml
index a4a20c8..829a948 100644
--- a/res/values-b+sr+Latn/strings.xml
+++ b/res/values-b+sr+Latn/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Obriši"</string>
<string name="allow" msgid="8885707816848569619">"Dozvoli"</string>
<string name="deny" msgid="6040983710442068936">"Odbij"</string>
+ <string name="add" msgid="2894574044585549298">"Dodaj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži izabrano"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> audio datoteku?</item>
<item quantity="few">Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> audio datoteke?</item>
diff --git a/res/values-be/strings.xml b/res/values-be/strings.xml
index 18c1dcc..4797eff 100644
--- a/res/values-be/strings.xml
+++ b/res/values-be/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Ачысціць"</string>
<string name="allow" msgid="8885707816848569619">"Дазволіць"</string>
<string name="deny" msgid="6040983710442068936">"Адмовіць"</string>
+ <string name="add" msgid="2894574044585549298">"Дадаць"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Праглядзець выбранае"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайл?</item>
<item quantity="few">Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайлы?</item>
diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml
index 878ad13..ae465a1 100644
--- a/res/values-bg/strings.xml
+++ b/res/values-bg/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Изчистване"</string>
<string name="allow" msgid="8885707816848569619">"Разрешаване"</string>
<string name="deny" msgid="6040983710442068936">"Отказ"</string>
+ <string name="add" msgid="2894574044585549298">"Добавяне"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Преглед на избраното"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Да се разреши ли на <xliff:g id="APP_NAME_1">^1</xliff:g> да промени <xliff:g id="COUNT">^2</xliff:g> аудиофайла?</item>
<item quantity="one">Да се разреши ли на <xliff:g id="APP_NAME_0">^1</xliff:g> да промени този аудиофайл?</item>
diff --git a/res/values-bn/strings.xml b/res/values-bn/strings.xml
index 06b23e7..3b282de 100644
--- a/res/values-bn/strings.xml
+++ b/res/values-bn/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"সরান"</string>
<string name="allow" msgid="8885707816848569619">"অনুমতি দিন"</string>
<string name="deny" msgid="6040983710442068936">"বাতিল করুন"</string>
+ <string name="add" msgid="2894574044585549298">"যোগ করুন"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ভিউ বেছে নেওয়া হয়েছে"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one"><xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি অডিও ফাইল পরিবর্তন করার অনুমতি দিতে চান?</item>
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি অডিও ফাইল পরিবর্তন করার অনুমতি দিতে চান?</item>
diff --git a/res/values-bs/strings.xml b/res/values-bs/strings.xml
index 791b3a4..8dd78bd 100644
--- a/res/values-bs/strings.xml
+++ b/res/values-bs/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Obriši"</string>
<string name="allow" msgid="8885707816848569619">"Dozvoli"</string>
<string name="deny" msgid="6040983710442068936">"Odbij"</string>
+ <string name="add" msgid="2894574044585549298">"Dodaj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži odabrano"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Dozvoliti da <xliff:g id="APP_NAME_1">^1</xliff:g> izmijeni <xliff:g id="COUNT">^2</xliff:g> audio fajl?</item>
<item quantity="few">Dozvoliti da <xliff:g id="APP_NAME_1">^1</xliff:g> izmijeni <xliff:g id="COUNT">^2</xliff:g> audio fajla?</item>
diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml
index 9341998..2c3156e 100644
--- a/res/values-ca/strings.xml
+++ b/res/values-ca/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Esborra"</string>
<string name="allow" msgid="8885707816848569619">"Permet"</string>
<string name="deny" msgid="6040983710442068936">"Denega"</string>
+ <string name="add" msgid="2894574044585549298">"Afegeix"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Mostra els elements seleccionats"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Vols permetre que <xliff:g id="APP_NAME_1">^1</xliff:g> modifiqui <xliff:g id="COUNT">^2</xliff:g> fitxers d\'àudio?</item>
<item quantity="one">Vols permetre que <xliff:g id="APP_NAME_0">^1</xliff:g> modifiqui aquest fitxer d\'àudio?</item>
diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml
index eab7293..3404e66 100644
--- a/res/values-cs/strings.xml
+++ b/res/values-cs/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Vymazat"</string>
<string name="allow" msgid="8885707816848569619">"Povolit"</string>
<string name="deny" msgid="6040983710442068936">"Zakázat"</string>
+ <string name="add" msgid="2894574044585549298">"Přidat"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Zobrazit vybrané"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="few">Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> zvukové soubory?</item>
<item quantity="many">Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> zvukového souboru?</item>
diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml
index e5bba25..615843e 100644
--- a/res/values-da/strings.xml
+++ b/res/values-da/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Ryd"</string>
<string name="allow" msgid="8885707816848569619">"Tillad"</string>
<string name="deny" msgid="6040983710442068936">"Afvis"</string>
+ <string name="add" msgid="2894574044585549298">"Tilføj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Visning valgt"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Vil du give <xliff:g id="APP_NAME_1">^1</xliff:g> tilladelse til at ændre <xliff:g id="COUNT">^2</xliff:g> lydfil?</item>
<item quantity="other">Vil du give <xliff:g id="APP_NAME_1">^1</xliff:g> tilladelse til at ændre <xliff:g id="COUNT">^2</xliff:g> lydfiler?</item>
diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml
index 6a0001a..44b0a02 100644
--- a/res/values-de/strings.xml
+++ b/res/values-de/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Löschen"</string>
<string name="allow" msgid="8885707816848569619">"Zulassen"</string>
<string name="deny" msgid="6040983710442068936">"Ablehnen"</string>
+ <string name="add" msgid="2894574044585549298">"Hinzufügen"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Auswahl ansehen"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Darf <xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> Audiodateien ändern?</item>
<item quantity="one">Darf <xliff:g id="APP_NAME_0">^1</xliff:g> diese Audiodatei ändern?</item>
diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml
index e50e8f8..739fac5 100644
--- a/res/values-el/strings.xml
+++ b/res/values-el/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Διαγραφή"</string>
<string name="allow" msgid="8885707816848569619">"Να επιτρέπεται"</string>
<string name="deny" msgid="6040983710442068936">"Απόρριψη"</string>
+ <string name="add" msgid="2894574044585549298">"Προσθήκη"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Προβολή επιλεγμένων"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_1">^1</xliff:g> η τροποποίηση <xliff:g id="COUNT">^2</xliff:g> αρχείων ήχου;</item>
<item quantity="one">Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_0">^1</xliff:g> η τροποποίηση αυτού του αρχείου ήχου;</item>
diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml
index 2f1bda0..c877592 100644
--- a/res/values-en-rAU/strings.xml
+++ b/res/values-en-rAU/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Clear"</string>
<string name="allow" msgid="8885707816848569619">"Allow"</string>
<string name="deny" msgid="6040983710442068936">"Deny"</string>
+ <string name="add" msgid="2894574044585549298">"Add"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
<item quantity="one">Allow <xliff:g id="APP_NAME_0">^1</xliff:g> to modify this audio file?</item>
diff --git a/res/values-en-rCA/strings.xml b/res/values-en-rCA/strings.xml
index 2f1bda0..c877592 100644
--- a/res/values-en-rCA/strings.xml
+++ b/res/values-en-rCA/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Clear"</string>
<string name="allow" msgid="8885707816848569619">"Allow"</string>
<string name="deny" msgid="6040983710442068936">"Deny"</string>
+ <string name="add" msgid="2894574044585549298">"Add"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
<item quantity="one">Allow <xliff:g id="APP_NAME_0">^1</xliff:g> to modify this audio file?</item>
diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml
index 2f1bda0..c877592 100644
--- a/res/values-en-rGB/strings.xml
+++ b/res/values-en-rGB/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Clear"</string>
<string name="allow" msgid="8885707816848569619">"Allow"</string>
<string name="deny" msgid="6040983710442068936">"Deny"</string>
+ <string name="add" msgid="2894574044585549298">"Add"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
<item quantity="one">Allow <xliff:g id="APP_NAME_0">^1</xliff:g> to modify this audio file?</item>
diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml
index 2f1bda0..c877592 100644
--- a/res/values-en-rIN/strings.xml
+++ b/res/values-en-rIN/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Clear"</string>
<string name="allow" msgid="8885707816848569619">"Allow"</string>
<string name="deny" msgid="6040983710442068936">"Deny"</string>
+ <string name="add" msgid="2894574044585549298">"Add"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
<item quantity="one">Allow <xliff:g id="APP_NAME_0">^1</xliff:g> to modify this audio file?</item>
diff --git a/res/values-en-rXC/strings.xml b/res/values-en-rXC/strings.xml
index 707a0f5..236d143 100644
--- a/res/values-en-rXC/strings.xml
+++ b/res/values-en-rXC/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Clear"</string>
<string name="allow" msgid="8885707816848569619">"Allow"</string>
<string name="deny" msgid="6040983710442068936">"Deny"</string>
+ <string name="add" msgid="2894574044585549298">"Add"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
<item quantity="one">Allow <xliff:g id="APP_NAME_0">^1</xliff:g> to modify this audio file?</item>
diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml
index 016c843..db36042 100644
--- a/res/values-es-rUS/strings.xml
+++ b/res/values-es-rUS/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Borrar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Rechazar"</string>
+ <string name="add" msgid="2894574044585549298">"Agregar"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver contenido seleccionado"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">¿Deseas permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?</item>
<item quantity="one">¿Deseas permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este archivo de audio?</item>
diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml
index 89b404f..401a2f7 100644
--- a/res/values-es/strings.xml
+++ b/res/values-es/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Borrar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Denegar"</string>
+ <string name="add" msgid="2894574044585549298">"Añadir"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver elementos seleccionados"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">¿Permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?</item>
<item quantity="one">¿Permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este archivo de audio?</item>
diff --git a/res/values-et/strings.xml b/res/values-et/strings.xml
index 27a526a..67a7ed3 100644
--- a/res/values-et/strings.xml
+++ b/res/values-et/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Kustuta"</string>
<string name="allow" msgid="8885707816848569619">"Luba"</string>
<string name="deny" msgid="6040983710442068936">"Keela"</string>
+ <string name="add" msgid="2894574044585549298">"Lisa"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Kuva valitud"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Kas lubada rakendusel <xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> helifaili muuta?</item>
<item quantity="one">Kas lubada rakendusel <xliff:g id="APP_NAME_0">^1</xliff:g> seda helifaili muuta?</item>
diff --git a/res/values-eu/strings.xml b/res/values-eu/strings.xml
index 707e083..77a30ba 100644
--- a/res/values-eu/strings.xml
+++ b/res/values-eu/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Garbitu"</string>
<string name="allow" msgid="8885707816848569619">"Eman baimena"</string>
<string name="deny" msgid="6040983710442068936">"Ukatu"</string>
+ <string name="add" msgid="2894574044585549298">"Gehitu"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ikusi hautatutakoak"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g> audio-fitxategiri aldaketak egiteko baimena eman nahi diozu <xliff:g id="APP_NAME_1">^1</xliff:g> aplikazioari?</item>
<item quantity="one">Audio-fitxategi honi aldaketak egiteko baimena eman nahi diozu <xliff:g id="APP_NAME_0">^1</xliff:g> aplikazioari?</item>
diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml
index deb3250..5f51906 100644
--- a/res/values-fa/strings.xml
+++ b/res/values-fa/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"پاک کردن"</string>
<string name="allow" msgid="8885707816848569619">"اجازه دادن"</string>
<string name="deny" msgid="6040983710442068936">"مجاز نبودن"</string>
+ <string name="add" msgid="2894574044585549298">"افزودن"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"مشاهده موارد انتخابشده"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> فایل صوتی را تغییر دهد؟</item>
<item quantity="other">به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> فایل صوتی را تغییر دهد؟</item>
diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml
index 9e65ae7..9a3080c 100644
--- a/res/values-fi/strings.xml
+++ b/res/values-fi/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Poista"</string>
<string name="allow" msgid="8885707816848569619">"Salli"</string>
<string name="deny" msgid="6040983710442068936">"Estä"</string>
+ <string name="add" msgid="2894574044585549298">"Lisää"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Katso valitut"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Saako <xliff:g id="APP_NAME_1">^1</xliff:g> muokata <xliff:g id="COUNT">^2</xliff:g> audiotiedostoa?</item>
<item quantity="one">Saako <xliff:g id="APP_NAME_0">^1</xliff:g> muokata tätä audiotiedostoa?</item>
diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml
index de7245c..44bb93d 100644
--- a/res/values-fr-rCA/strings.xml
+++ b/res/values-fr-rCA/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Effacer"</string>
<string name="allow" msgid="8885707816848569619">"Autoriser"</string>
<string name="deny" msgid="6040983710442068936">"Refuser"</string>
+ <string name="add" msgid="2894574044585549298">"Ajouter"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Afficher le contenu sélectionné"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichier audio?</item>
<item quantity="other">Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio?</item>
diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml
index c995739..f152cb3 100644
--- a/res/values-fr/strings.xml
+++ b/res/values-fr/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Effacer"</string>
<string name="allow" msgid="8885707816848569619">"Autoriser"</string>
<string name="deny" msgid="6040983710442068936">"Refuser"</string>
+ <string name="add" msgid="2894574044585549298">"Ajouter"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Afficher la sélection"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Autoriser l\'application <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichier audio ?</item>
<item quantity="other">Autoriser l\'application <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio ?</item>
diff --git a/res/values-gl/strings.xml b/res/values-gl/strings.xml
index aa53246..848b435 100644
--- a/res/values-gl/strings.xml
+++ b/res/values-gl/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Borrar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Denegar"</string>
+ <string name="add" msgid="2894574044585549298">"Engadir"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver elemento seleccionado"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Queres permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> ficheiros de audio?</item>
<item quantity="one">Queres permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este ficheiro de audio?</item>
diff --git a/res/values-gu/strings.xml b/res/values-gu/strings.xml
index 3cffaf6..e2ed54d 100644
--- a/res/values-gu/strings.xml
+++ b/res/values-gu/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"સાફ કરો"</string>
<string name="allow" msgid="8885707816848569619">"મંજૂરી આપો"</string>
<string name="deny" msgid="6040983710442068936">"નકારો"</string>
+ <string name="add" msgid="2894574044585549298">"ઉમેરો"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"પસંદ કરેલી ટેક્સ્ટ જુઓ"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one"><xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરવાની મંજૂરી આપીએ?</item>
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરવાની મંજૂરી આપીએ?</item>
diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml
index 5881984..3c0efae 100644
--- a/res/values-hi/strings.xml
+++ b/res/values-hi/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"मिटाएं"</string>
<string name="allow" msgid="8885707816848569619">"अनुमति दें"</string>
<string name="deny" msgid="6040983710442068936">"अनुमति न दें"</string>
+ <string name="add" msgid="2894574044585549298">"जोड़ें"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"चुनी गई फ़ोटो या वीडियो देखें"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">क्या आप <xliff:g id="APP_NAME_1">^1</xliff:g> को <xliff:g id="COUNT">^2</xliff:g> ऑडियो फ़ाइल में बदलाव करने की अनुमति देना चाहते हैं?</item>
<item quantity="other">क्या आप <xliff:g id="APP_NAME_1">^1</xliff:g> को <xliff:g id="COUNT">^2</xliff:g> ऑडियो फ़ाइलों में बदलाव करने की अनुमति देना चाहते हैं?</item>
diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml
index 776b290..2e8db3d 100644
--- a/res/values-hr/strings.xml
+++ b/res/values-hr/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Izbriši"</string>
<string name="allow" msgid="8885707816848569619">"Dopusti"</string>
<string name="deny" msgid="6040983710442068936">"Odbij"</string>
+ <string name="add" msgid="2894574044585549298">"Dodaj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži odabrano"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> audiodatoteku?</item>
<item quantity="few">Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> audiodatoteke?</item>
diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml
index e3ea512..f039b4c 100644
--- a/res/values-hu/strings.xml
+++ b/res/values-hu/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Törlés"</string>
<string name="allow" msgid="8885707816848569619">"Engedélyezés"</string>
<string name="deny" msgid="6040983710442068936">"Tiltás"</string>
+ <string name="add" msgid="2894574044585549298">"Hozzáadás"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Kijelöltek megtekintése"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Engedélyezi a(z) <xliff:g id="APP_NAME_1">^1</xliff:g> számára <xliff:g id="COUNT">^2</xliff:g> hangfájl módosítását?</item>
<item quantity="one">Engedélyezi a(z) <xliff:g id="APP_NAME_0">^1</xliff:g> számára ennek a hangfájlnak a módosítását?</item>
diff --git a/res/values-hy/strings.xml b/res/values-hy/strings.xml
index 095e238..3684018 100644
--- a/res/values-hy/strings.xml
+++ b/res/values-hy/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Ջնջել"</string>
<string name="allow" msgid="8885707816848569619">"Թույլատրել"</string>
<string name="deny" msgid="6040983710442068936">"Մերժել"</string>
+ <string name="add" msgid="2894574044585549298">"Ավելացնել"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Դիտել ընտրությունը"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին վերականգնել <xliff:g id="COUNT">^2</xliff:g> աուդիո ֆայլ աղբարկղից</item>
<item quantity="other">Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին վերականգնել <xliff:g id="COUNT">^2</xliff:g> աուդիո ֆայլ աղբարկղից</item>
diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml
index 3987c55..a65b4d9 100644
--- a/res/values-in/strings.xml
+++ b/res/values-in/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Hapus"</string>
<string name="allow" msgid="8885707816848569619">"Izinkan"</string>
<string name="deny" msgid="6040983710442068936">"Tolak"</string>
+ <string name="add" msgid="2894574044585549298">"Tambahkan"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Lihat yang dipilih"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Izinkan <xliff:g id="APP_NAME_1">^1</xliff:g> untuk mengubah <xliff:g id="COUNT">^2</xliff:g> file audio?</item>
<item quantity="one">Izinkan <xliff:g id="APP_NAME_0">^1</xliff:g> untuk mengubah file audio ini?</item>
diff --git a/res/values-is/strings.xml b/res/values-is/strings.xml
index cf0d7c8..b8e089e 100644
--- a/res/values-is/strings.xml
+++ b/res/values-is/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Hreinsa"</string>
<string name="allow" msgid="8885707816848569619">"Leyfa"</string>
<string name="deny" msgid="6040983710442068936">"Hafna"</string>
+ <string name="add" msgid="2894574044585549298">"Bæta við"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Skoða valið"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> hljóðskrá?</item>
<item quantity="other">Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> hljóðskrám?</item>
diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml
index 7906977..64554d8 100644
--- a/res/values-it/strings.xml
+++ b/res/values-it/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Cancella"</string>
<string name="allow" msgid="8885707816848569619">"Consenti"</string>
<string name="deny" msgid="6040983710442068936">"Rifiuta"</string>
+ <string name="add" msgid="2894574044585549298">"Aggiungi"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Visualizza selezione"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Consentire all\'app <xliff:g id="APP_NAME_1">^1</xliff:g> di modificare <xliff:g id="COUNT">^2</xliff:g> file audio?</item>
<item quantity="one">Consentire all\'app <xliff:g id="APP_NAME_0">^1</xliff:g> di modificare questo file audio?</item>
diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml
index 4552372..3e41502 100644
--- a/res/values-iw/strings.xml
+++ b/res/values-iw/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"ניקוי"</string>
<string name="allow" msgid="8885707816848569619">"אישור"</string>
<string name="deny" msgid="6040983710442068936">"דחייה"</string>
+ <string name="add" msgid="2894574044585549298">"הוספה"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"הצגת הפריטים שנבחרו"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="two">לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> קובצי אודיו?</item>
<item quantity="many">לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> קובצי אודיו?</item>
diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml
index 58cc9b0..a58892b 100644
--- a/res/values-ja/strings.xml
+++ b/res/values-ja/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"削除"</string>
<string name="allow" msgid="8885707816848569619">"許可"</string>
<string name="deny" msgid="6040983710442068936">"許可しない"</string>
+ <string name="add" msgid="2894574044585549298">"追加"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"選択した写真を見る"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g> 件の音声ファイルの変更を <xliff:g id="APP_NAME_1">^1</xliff:g> に許可しますか?</item>
<item quantity="one">この音声ファイルの変更を <xliff:g id="APP_NAME_0">^1</xliff:g> に許可しますか?</item>
diff --git a/res/values-ka/strings.xml b/res/values-ka/strings.xml
index d7d8808..aec833d 100644
--- a/res/values-ka/strings.xml
+++ b/res/values-ka/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"გასუფთავება"</string>
<string name="allow" msgid="8885707816848569619">"დაშვება"</string>
<string name="deny" msgid="6040983710442068936">"უარყოფა"</string>
+ <string name="add" msgid="2894574044585549298">"დამატება"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ხედი არჩეულია"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">აძლევთ უფლებას <xliff:g id="APP_NAME_1">^1</xliff:g>-ს, შეცვალოს <xliff:g id="COUNT">^2</xliff:g> აუდიოფაილი?</item>
<item quantity="one">აძლევთ უფლებას <xliff:g id="APP_NAME_0">^1</xliff:g>-ს, შეცვალოს ეს აუდიოფაილი?</item>
diff --git a/res/values-kk/strings.xml b/res/values-kk/strings.xml
index f6a9252..c3024f3 100644
--- a/res/values-kk/strings.xml
+++ b/res/values-kk/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Өшіру"</string>
<string name="allow" msgid="8885707816848569619">"Рұқсат ету"</string>
<string name="deny" msgid="6040983710442068936">"Тыйым салу"</string>
+ <string name="add" msgid="2894574044585549298">"Қосу"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Таңдалғанды көру"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> қолданбасына <xliff:g id="COUNT">^2</xliff:g> аудиофайлды өзгертуге рұқсат етесіз бе?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> қолданбасына осы аудиофайлды өзгертуге рұқсат етесіз бе?</item>
diff --git a/res/values-km/strings.xml b/res/values-km/strings.xml
index d7662d6..c1305ff 100644
--- a/res/values-km/strings.xml
+++ b/res/values-km/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"សម្អាត"</string>
<string name="allow" msgid="8885707816848569619">"អនុញ្ញាត"</string>
<string name="deny" msgid="6040983710442068936">"បដិសេធ"</string>
+ <string name="add" msgid="2894574044585549298">"បញ្ចូល"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"បានជ្រើសរើសទិដ្ឋភាព"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">អនុញ្ញាតឱ្យ <xliff:g id="APP_NAME_1">^1</xliff:g> កែឯកសារសំឡេង <xliff:g id="COUNT">^2</xliff:g> ឬ?</item>
<item quantity="one">អនុញ្ញាតឱ្យ <xliff:g id="APP_NAME_0">^1</xliff:g> កែឯកសារសំឡេងនេះឬ?</item>
diff --git a/res/values-kn/strings.xml b/res/values-kn/strings.xml
index ca1da24..fc0c983 100644
--- a/res/values-kn/strings.xml
+++ b/res/values-kn/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"ತೆರವುಗೊಳಿಸಿ"</string>
<string name="allow" msgid="8885707816848569619">"ಅನುಮತಿಸಿ"</string>
<string name="deny" msgid="6040983710442068936">"ನಿರಾಕರಿಸಿ"</string>
+ <string name="add" msgid="2894574044585549298">"ಸೇರಿಸಿ"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ಆಯ್ಕೆಮಾಡಿರುವುದನ್ನು ವೀಕ್ಷಿಸಿ"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">ಈ <xliff:g id="COUNT">^2</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?</item>
<item quantity="other">ಈ <xliff:g id="COUNT">^2</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?</item>
diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml
index afb14cc..c4f069e 100644
--- a/res/values-ko/strings.xml
+++ b/res/values-ko/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"삭제"</string>
<string name="allow" msgid="8885707816848569619">"허용"</string>
<string name="deny" msgid="6040983710442068936">"거부"</string>
+ <string name="add" msgid="2894574044585549298">"추가"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"선택 항목 보기"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>에서 오디오 파일 <xliff:g id="COUNT">^2</xliff:g>개를 수정하도록 허용하시겠습니까?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g>에서 이 오디오 파일을 수정하도록 허용하시겠습니까?</item>
diff --git a/res/values-ky/strings.xml b/res/values-ky/strings.xml
index f6b244b..2c7c805 100644
--- a/res/values-ky/strings.xml
+++ b/res/values-ky/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Тазалоо"</string>
<string name="allow" msgid="8885707816848569619">"Ооба"</string>
<string name="deny" msgid="6040983710442068936">"Жок"</string>
+ <string name="add" msgid="2894574044585549298">"Кошуу"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Көрүнүш тандалды"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> колдонмосу <xliff:g id="COUNT">^2</xliff:g> аудио файлды өзгөртсүнбү?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> колдонмосу бул аудио файлды өзгөртсүнбү?</item>
diff --git a/res/values-lo/strings.xml b/res/values-lo/strings.xml
index c21aec4..7121abe 100644
--- a/res/values-lo/strings.xml
+++ b/res/values-lo/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"ລຶບລ້າງ"</string>
<string name="allow" msgid="8885707816848569619">"ອະນຸຍາດ"</string>
<string name="deny" msgid="6040983710442068936">"ປະຕິເສດ"</string>
+ <string name="add" msgid="2894574044585549298">"ເພີ່ມ"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ເບິ່ງອັນທີ່ເລືອກໄວ້"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_1">^1</xliff:g> ແກ້ໄຂໄຟລ໌ສຽງ <xliff:g id="COUNT">^2</xliff:g> ໄຟລ໌ບໍ?</item>
<item quantity="one">ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_0">^1</xliff:g> ແກ້ໄຂໄຟລ໌ສຽງນີ້ບໍ?</item>
diff --git a/res/values-lt/strings.xml b/res/values-lt/strings.xml
index c4f30d9..b30c748 100644
--- a/res/values-lt/strings.xml
+++ b/res/values-lt/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Išvalyti"</string>
<string name="allow" msgid="8885707816848569619">"Leisti"</string>
<string name="deny" msgid="6040983710442068936">"Atmesti"</string>
+ <string name="add" msgid="2894574044585549298">"Pridėti"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Žiūrėti pasirinktus"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failą?</item>
<item quantity="few">Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failus?</item>
diff --git a/res/values-lv/strings.xml b/res/values-lv/strings.xml
index fbd2cba..d8974a1 100644
--- a/res/values-lv/strings.xml
+++ b/res/values-lv/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Notīrīt"</string>
<string name="allow" msgid="8885707816848569619">"Atļaut"</string>
<string name="deny" msgid="6040983710442068936">"Neatļaut"</string>
+ <string name="add" msgid="2894574044585549298">"Pievienot"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Skatīt atlasīto"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="zero">Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> modificēt <xliff:g id="COUNT">^2</xliff:g> audio failus?</item>
<item quantity="one">Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> modificēt <xliff:g id="COUNT">^2</xliff:g> audio failu?</item>
diff --git a/res/values-mk/strings.xml b/res/values-mk/strings.xml
index 3fad90a..e326f69 100644
--- a/res/values-mk/strings.xml
+++ b/res/values-mk/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Избриши"</string>
<string name="allow" msgid="8885707816848569619">"Дозволи"</string>
<string name="deny" msgid="6040983710442068936">"Одбиј"</string>
+ <string name="add" msgid="2894574044585549298">"Додај"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Прикажи ги избраните"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> аудиодатотека?</item>
<item quantity="other">Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> аудиодатотеки?</item>
diff --git a/res/values-ml/strings.xml b/res/values-ml/strings.xml
index a11ddef..cba7185 100644
--- a/res/values-ml/strings.xml
+++ b/res/values-ml/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"മായ്ക്കുക"</string>
<string name="allow" msgid="8885707816848569619">"അനുവദിക്കൂ"</string>
<string name="deny" msgid="6040983710442068936">"നിരസിക്കുക"</string>
+ <string name="add" msgid="2894574044585549298">"ചേർക്കുക"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"തിരഞ്ഞെടുത്തത് കാണുക"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g> ഓഡിയോ ഫയലുകൾ പരിഷ്കരിക്കാൻ <xliff:g id="APP_NAME_1">^1</xliff:g> എന്നതിനെ അനുവദിക്കണോ?</item>
<item quantity="one">ഈ ഓഡിയോ ഫയൽ പരിഷ്കരിക്കാൻ <xliff:g id="APP_NAME_0">^1</xliff:g> എന്നതിനെ അനുവദിക്കണോ?</item>
diff --git a/res/values-mn/strings.xml b/res/values-mn/strings.xml
index 3b791d8..6ff4cd0 100644
--- a/res/values-mn/strings.xml
+++ b/res/values-mn/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Арилгах"</string>
<string name="allow" msgid="8885707816848569619">"Зөвшөөрөх"</string>
<string name="deny" msgid="6040983710442068936">"Татгалзах"</string>
+ <string name="add" msgid="2894574044585549298">"Нэмэх"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Сонгосныг харах"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>-д <xliff:g id="COUNT">^2</xliff:g> аудио файл өөрчлөхийг зөвшөөрөх үү?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g>-д энэ аудио файлыг өөрчлөхийг зөвшөөрөх үү?</item>
diff --git a/res/values-mr/strings.xml b/res/values-mr/strings.xml
index 98bef88..68e87a9 100644
--- a/res/values-mr/strings.xml
+++ b/res/values-mr/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"साफ करा"</string>
<string name="allow" msgid="8885707816848569619">"अनुमती द्या"</string>
<string name="deny" msgid="6040983710442068936">"नकार द्या"</string>
+ <string name="add" msgid="2894574044585549298">"जोडा"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"दृश्य निवडले"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> ला <xliff:g id="COUNT">^2</xliff:g> ऑडिओ फाइल सुधारित करण्याची परवानगी द्यायची आहे का?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> ला ही ऑडिओ फाइल सुधारित करण्याची परवानगी द्यायची आहे का?</item>
diff --git a/res/values-ms/strings.xml b/res/values-ms/strings.xml
index dc5d040..d88b933 100644
--- a/res/values-ms/strings.xml
+++ b/res/values-ms/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Kosongkan"</string>
<string name="allow" msgid="8885707816848569619">"Benarkan"</string>
<string name="deny" msgid="6040983710442068936">"Tolak"</string>
+ <string name="add" msgid="2894574044585549298">"Tambah"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Lihat terpilih"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Benarkan <xliff:g id="APP_NAME_1">^1</xliff:g> mengubah suai <xliff:g id="COUNT">^2</xliff:g> fail audio?</item>
<item quantity="one">Benarkan <xliff:g id="APP_NAME_0">^1</xliff:g> mengubah suai fail audio ini?</item>
diff --git a/res/values-my/strings.xml b/res/values-my/strings.xml
index ce0b311..d6ce456 100644
--- a/res/values-my/strings.xml
+++ b/res/values-my/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"ရှင်းရန်"</string>
<string name="allow" msgid="8885707816848569619">"ခွင့်ပြုရန်"</string>
<string name="deny" msgid="6040983710442068936">"ပယ်ရန်"</string>
+ <string name="add" msgid="2894574044585549298">"ထည့်ရန်"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ပြသမှုကို ရွေးချယ်ထားသည်"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> ကို အသံဖိုင် <xliff:g id="COUNT">^2</xliff:g> ဖိုင် ပြင်ဆင်ခွင့်ပြုမလား။</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> ကို ဤအသံဖိုင် ပြင်ဆင်ခွင့်ပြုမလား။</item>
diff --git a/res/values-nb/strings.xml b/res/values-nb/strings.xml
index 3ebc4c1..c33e226 100644
--- a/res/values-nb/strings.xml
+++ b/res/values-nb/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Slett"</string>
<string name="allow" msgid="8885707816848569619">"Tillat"</string>
<string name="deny" msgid="6040983710442068936">"Avvis"</string>
+ <string name="add" msgid="2894574044585549298">"Legg til"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Vis valgte"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Vil du tillate at <xliff:g id="APP_NAME_1">^1</xliff:g> endrer <xliff:g id="COUNT">^2</xliff:g> lydfiler?</item>
<item quantity="one">Vil du tillate at <xliff:g id="APP_NAME_0">^1</xliff:g> endrer denne lydfilen?</item>
diff --git a/res/values-ne/strings.xml b/res/values-ne/strings.xml
index ea47c97..fabca2e 100644
--- a/res/values-ne/strings.xml
+++ b/res/values-ne/strings.xml
@@ -43,6 +43,9 @@
<string name="clear" msgid="5524638938415865915">"हटाउनुहोस्"</string>
<string name="allow" msgid="8885707816848569619">"अनुमति दिनुहोस्"</string>
<string name="deny" msgid="6040983710442068936">"अस्वीकार गर्नुहोस्"</string>
+ <string name="add" msgid="2894574044585549298">"हाल्नुहोस्"</string>
+ <!-- no translation found for picker_view_selected (2266031384396143883) -->
+ <skip />
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> लाई यी <xliff:g id="COUNT">^2</xliff:g> अडियो फाइलहरू परिमार्जन गर्न दिने हो?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> लाई यो अडियो फाइल परिमार्जन गर्न दिने हो?</item>
diff --git a/res/values-night/colors.xml b/res/values-night/colors.xml
index 1478eb2..cec3a2b 100644
--- a/res/values-night/colors.xml
+++ b/res/values-night/colors.xml
@@ -18,4 +18,9 @@
<resources>
<color name="thumb_gray_color">#3C4043</color>
<color name="clear_cache_icon_color">#DADCE0</color>
+
+ <!-- PhotoPicker -->
+ <color name="picker_primary_color">#8AB4F8</color>
+ <color name="picker_background_color">#202124</color>
+ <color name="picker_highlight_color">#3D8AB4F8</color>
</resources>
diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml
index dcd8a36..d29b05e 100644
--- a/res/values-nl/strings.xml
+++ b/res/values-nl/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Wissen"</string>
<string name="allow" msgid="8885707816848569619">"Toestaan"</string>
<string name="deny" msgid="6040983710442068936">"Weigeren"</string>
+ <string name="add" msgid="2894574044585549298">"Toevoegen"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Selectie bekijken"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> toestaan <xliff:g id="COUNT">^2</xliff:g> audiobestanden te wijzigen?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> toestaan dit audiobestand te wijzigen?</item>
diff --git a/res/values-or/strings.xml b/res/values-or/strings.xml
index 1e185dc..3aeb1d5 100644
--- a/res/values-or/strings.xml
+++ b/res/values-or/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"ଖାଲି କରନ୍ତୁ"</string>
<string name="allow" msgid="8885707816848569619">"ଅନୁମତି ଦିଅନ୍ତୁ"</string>
<string name="deny" msgid="6040983710442068936">"ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ"</string>
+ <string name="add" msgid="2894574044585549298">"ଯୋଗ କରନ୍ତୁ"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ଚୟନିତଗୁଡ଼ିକୁ ଦେଖନ୍ତୁ"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g>ଟି ଅଡିଓ ଫାଇଲକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_1">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?</item>
<item quantity="one">ଏହି ଅଡିଓ ଫାଇଲକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_0">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?</item>
diff --git a/res/values-pa/strings.xml b/res/values-pa/strings.xml
index 273c6b6..605cee1 100644
--- a/res/values-pa/strings.xml
+++ b/res/values-pa/strings.xml
@@ -43,6 +43,9 @@
<string name="clear" msgid="5524638938415865915">"ਕਲੀਅਰ ਕਰੋ"</string>
<string name="allow" msgid="8885707816848569619">"ਆਗਿਆ ਦਿਓ"</string>
<string name="deny" msgid="6040983710442068936">"ਮਨ੍ਹਾ ਕਰੋ"</string>
+ <string name="add" msgid="2894574044585549298">"ਸ਼ਾਮਲ ਕਰੋ"</string>
+ <!-- no translation found for picker_view_selected (2266031384396143883) -->
+ <skip />
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?</item>
<item quantity="other">ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲਾਂ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?</item>
diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml
index d308c74..4e49486 100644
--- a/res/values-pl/strings.xml
+++ b/res/values-pl/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Wyczyść"</string>
<string name="allow" msgid="8885707816848569619">"Zezwól"</string>
<string name="deny" msgid="6040983710442068936">"Odmów"</string>
+ <string name="add" msgid="2894574044585549298">"Dodaj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Wyświetl wybrane"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="few">Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> plików audio?</item>
<item quantity="many">Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> plików audio?</item>
diff --git a/res/values-pt-rBR/strings.xml b/res/values-pt-rBR/strings.xml
index 82f8b43..e9dbf84 100644
--- a/res/values-pt-rBR/strings.xml
+++ b/res/values-pt-rBR/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Limpar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Negar"</string>
+ <string name="add" msgid="2894574044585549298">"Adicionar"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver selecionado"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Permitir que o app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> arquivo de áudio?</item>
<item quantity="other">Permitir que o app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> arquivos de áudio?</item>
diff --git a/res/values-pt-rPT/strings.xml b/res/values-pt-rPT/strings.xml
index 058b69b..a03f638 100644
--- a/res/values-pt-rPT/strings.xml
+++ b/res/values-pt-rPT/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Limpar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Recusar"</string>
+ <string name="add" msgid="2894574044585549298">"Adicionar"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver selecionado(s)"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Permitir que a app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> ficheiros de áudio?</item>
<item quantity="one">Permitir que a app <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este ficheiro de áudio?</item>
diff --git a/res/values-pt/strings.xml b/res/values-pt/strings.xml
index 82f8b43..e9dbf84 100644
--- a/res/values-pt/strings.xml
+++ b/res/values-pt/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Limpar"</string>
<string name="allow" msgid="8885707816848569619">"Permitir"</string>
<string name="deny" msgid="6040983710442068936">"Negar"</string>
+ <string name="add" msgid="2894574044585549298">"Adicionar"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ver selecionado"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Permitir que o app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> arquivo de áudio?</item>
<item quantity="other">Permitir que o app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> arquivos de áudio?</item>
diff --git a/res/values-ro/strings.xml b/res/values-ro/strings.xml
index 4bd0b43..e0c9994 100644
--- a/res/values-ro/strings.xml
+++ b/res/values-ro/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Ștergeți"</string>
<string name="allow" msgid="8885707816848569619">"Permiteți"</string>
<string name="deny" msgid="6040983710442068936">"Refuzați"</string>
+ <string name="add" msgid="2894574044585549298">"Adăugați"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Vedeți elementele selectate"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="few">Permiteți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> fișiere audio?</item>
<item quantity="other">Permiteți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> de fișiere audio?</item>
diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml
index 054d9fc..7bc1fc3 100644
--- a/res/values-ru/strings.xml
+++ b/res/values-ru/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Удалить"</string>
<string name="allow" msgid="8885707816848569619">"Разрешить"</string>
<string name="deny" msgid="6040983710442068936">"Запретить"</string>
+ <string name="add" msgid="2894574044585549298">"Добавить"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Смотреть выбранное"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайл?</item>
<item quantity="few">Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайла?</item>
diff --git a/res/values-si/strings.xml b/res/values-si/strings.xml
index 4b0b9bb..e224ed1 100644
--- a/res/values-si/strings.xml
+++ b/res/values-si/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"හිස් කරන්න"</string>
<string name="allow" msgid="8885707816848569619">"ඉඩ දෙන්න"</string>
<string name="deny" msgid="6040983710442068936">"ප්රතික්ෂේප කරන්න"</string>
+ <string name="add" msgid="2894574044585549298">"එක් කරන්න"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"තෝරා ගත් දේවල් බලන්න"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one"><xliff:g id="APP_NAME_1">^1</xliff:g>ට ඕඩියෝ ගොනු <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?</item>
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g>ට ඕඩියෝ ගොනු <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?</item>
diff --git a/res/values-sk/strings.xml b/res/values-sk/strings.xml
index fc0ea6b..30ef618 100644
--- a/res/values-sk/strings.xml
+++ b/res/values-sk/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Vymazať"</string>
<string name="allow" msgid="8885707816848569619">"Povoliť"</string>
<string name="deny" msgid="6040983710442068936">"Zamietnuť"</string>
+ <string name="add" msgid="2894574044585549298">"Pridať"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Zobraziť vybrané"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="few">Chcete povoliť aplikácii <xliff:g id="APP_NAME_1">^1</xliff:g> upraviť <xliff:g id="COUNT">^2</xliff:g> zvukové súbory?</item>
<item quantity="many">Allow <xliff:g id="APP_NAME_1">^1</xliff:g> to modify <xliff:g id="COUNT">^2</xliff:g> audio files?</item>
diff --git a/res/values-sl/strings.xml b/res/values-sl/strings.xml
index d4ac3a1..c99ba06 100644
--- a/res/values-sl/strings.xml
+++ b/res/values-sl/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Počisti"</string>
<string name="allow" msgid="8885707816848569619">"Dovoli"</string>
<string name="deny" msgid="6040983710442068936">"Zavrni"</string>
+ <string name="add" msgid="2894574044585549298">"Dodaj"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži izbrano"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Želite dovoliti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g>, da spremeni <xliff:g id="COUNT">^2</xliff:g> zvočno datoteko?</item>
<item quantity="two">Želite dovoliti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g>, da spremeni <xliff:g id="COUNT">^2</xliff:g> zvočni datoteki?</item>
diff --git a/res/values-sq/strings.xml b/res/values-sq/strings.xml
index d69a10c..e5011ed 100644
--- a/res/values-sq/strings.xml
+++ b/res/values-sq/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Pastro"</string>
<string name="allow" msgid="8885707816848569619">"Lejo"</string>
<string name="deny" msgid="6040983710442068936">"Refuzo"</string>
+ <string name="add" msgid="2894574044585549298">"Shto"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Shiko të zgjedhurat"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Të lejohet <xliff:g id="APP_NAME_1">^1</xliff:g> që të modifikojë <xliff:g id="COUNT">^2</xliff:g> skedarë audio?</item>
<item quantity="one">Të lejohet <xliff:g id="APP_NAME_0">^1</xliff:g> që ta modifikojë këtë skedar audio?</item>
diff --git a/res/values-sr/strings.xml b/res/values-sr/strings.xml
index cfc2aa8..48c51ab 100644
--- a/res/values-sr/strings.xml
+++ b/res/values-sr/strings.xml
@@ -45,6 +45,8 @@
<string name="clear" msgid="5524638938415865915">"Обриши"</string>
<string name="allow" msgid="8885707816848569619">"Дозволи"</string>
<string name="deny" msgid="6040983710442068936">"Одбиј"</string>
+ <string name="add" msgid="2894574044585549298">"Додај"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Прикажи изабранo"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> аудио датотеку?</item>
<item quantity="few">Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> аудио датотеке?</item>
diff --git a/res/values-sv/strings.xml b/res/values-sv/strings.xml
index a576b61..80f45ac 100644
--- a/res/values-sv/strings.xml
+++ b/res/values-sv/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Rensa"</string>
<string name="allow" msgid="8885707816848569619">"Tillåt"</string>
<string name="deny" msgid="6040983710442068936">"Neka"</string>
+ <string name="add" msgid="2894574044585549298">"Lägg till"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Visa valda"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Vill du tillåta att <xliff:g id="APP_NAME_1">^1</xliff:g> ändrar <xliff:g id="COUNT">^2</xliff:g> ljudfiler?</item>
<item quantity="one">Vill du tillåta att <xliff:g id="APP_NAME_0">^1</xliff:g> ändrar den här ljudfilen?</item>
diff --git a/res/values-sw/strings.xml b/res/values-sw/strings.xml
index 1357d44..9a04dd2 100644
--- a/res/values-sw/strings.xml
+++ b/res/values-sw/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Futa"</string>
<string name="allow" msgid="8885707816848569619">"Ruhusu"</string>
<string name="deny" msgid="6040983710442068936">"Kataa"</string>
+ <string name="add" msgid="2894574044585549298">"Weka"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Angalia uliyochagua"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Ungependa kuruhusu <xliff:g id="APP_NAME_1">^1</xliff:g> ibadilishe faili <xliff:g id="COUNT">^2</xliff:g> za sauti?</item>
<item quantity="one">Ungependa kuruhusu <xliff:g id="APP_NAME_0">^1</xliff:g> ibadilishe faili hii ya sauti?</item>
diff --git a/res/values-ta/strings.xml b/res/values-ta/strings.xml
index e1404f6..2b249d6 100644
--- a/res/values-ta/strings.xml
+++ b/res/values-ta/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"அழி"</string>
<string name="allow" msgid="8885707816848569619">"அனுமதி"</string>
<string name="deny" msgid="6040983710442068936">"நிராகரி"</string>
+ <string name="add" msgid="2894574044585549298">"சேர்"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"தேர்ந்தெடுத்தவற்றைக் காட்டு"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g> ஆடியோ ஃபைல்களில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_1">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?</item>
<item quantity="one">இந்த ஆடியோ ஃபைலில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_0">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?</item>
diff --git a/res/values-te/strings.xml b/res/values-te/strings.xml
index 2b14c3e..de57251 100644
--- a/res/values-te/strings.xml
+++ b/res/values-te/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"క్లియర్ చేయండి"</string>
<string name="allow" msgid="8885707816848569619">"అనుమతించు"</string>
<string name="deny" msgid="6040983710442068936">"నిరాకరించు"</string>
+ <string name="add" msgid="2894574044585549298">"జోడించు"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ఎంచుకున్న వాటిని చూడండి"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="COUNT">^2</xliff:g> ఆడియో ఫైల్లను సవరించడానికి <xliff:g id="APP_NAME_1">^1</xliff:g>ను అనుమతించాలా?</item>
<item quantity="one">ఈ ఆడియో ఫైల్ను సవరించడానికి <xliff:g id="APP_NAME_0">^1</xliff:g>ను అనుమతించాలా?</item>
diff --git a/res/values-th/strings.xml b/res/values-th/strings.xml
index da8604c..54222f7 100644
--- a/res/values-th/strings.xml
+++ b/res/values-th/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"ล้าง"</string>
<string name="allow" msgid="8885707816848569619">"อนุญาต"</string>
<string name="deny" msgid="6040983710442068936">"ปฏิเสธ"</string>
+ <string name="add" msgid="2894574044585549298">"เพิ่ม"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"ดูรายการที่เลือก"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">อนุญาตให้ <xliff:g id="APP_NAME_1">^1</xliff:g> แก้ไขไฟล์เสียง <xliff:g id="COUNT">^2</xliff:g> ไฟล์ไหม</item>
<item quantity="one">อนุญาตให้ <xliff:g id="APP_NAME_0">^1</xliff:g> แก้ไขไฟล์เสียงนี้ไหม</item>
diff --git a/res/values-tl/strings.xml b/res/values-tl/strings.xml
index 804e99e..d3763f6 100644
--- a/res/values-tl/strings.xml
+++ b/res/values-tl/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"I-clear"</string>
<string name="allow" msgid="8885707816848569619">"Payagan"</string>
<string name="deny" msgid="6040983710442068936">"Tanggihan"</string>
+ <string name="add" msgid="2894574044585549298">"Magdagdag"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Tingnan ang napili"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na i-modify ang <xliff:g id="COUNT">^2</xliff:g> audio file?</item>
<item quantity="other">Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na i-modify ang <xliff:g id="COUNT">^2</xliff:g> na audio file?</item>
diff --git a/res/values-tr/strings.xml b/res/values-tr/strings.xml
index e3efff8..3e221f5 100644
--- a/res/values-tr/strings.xml
+++ b/res/values-tr/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Temizle"</string>
<string name="allow" msgid="8885707816848569619">"İzin ver"</string>
<string name="deny" msgid="6040983710442068936">"Reddet"</string>
+ <string name="add" msgid="2894574044585549298">"Ekle"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Seçilenleri görüntüle"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> uygulamasının <xliff:g id="COUNT">^2</xliff:g> ses dosyasını değiştirmesine izin verilsin mi?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> uygulamasının bu ses dosyasını değiştirmesine izin verilsin mi?</item>
diff --git a/res/values-uk/strings.xml b/res/values-uk/strings.xml
index d03fb4c..d789b9c 100644
--- a/res/values-uk/strings.xml
+++ b/res/values-uk/strings.xml
@@ -47,6 +47,8 @@
<string name="clear" msgid="5524638938415865915">"Очистити"</string>
<string name="allow" msgid="8885707816848569619">"Дозволити"</string>
<string name="deny" msgid="6040983710442068936">"Заборонити"</string>
+ <string name="add" msgid="2894574044585549298">"Додати"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Переглянути вибране"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайл?</item>
<item quantity="few">Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайли?</item>
diff --git a/res/values-ur/strings.xml b/res/values-ur/strings.xml
index 63488cf..c7ea326 100644
--- a/res/values-ur/strings.xml
+++ b/res/values-ur/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"صاف کریں"</string>
<string name="allow" msgid="8885707816848569619">"اجازت دیں"</string>
<string name="deny" msgid="6040983710442068936">"مسترد کریں"</string>
+ <string name="add" msgid="2894574044585549298">"شامل کریں"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"منتخب کردہ دیکھیں"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> کو <xliff:g id="COUNT">^2</xliff:g> آڈیو فائلز میں ترمیم کرنے کی اجازت دیں؟</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> کو اس آڈیو فائل میں ترمیم کرنے کی اجازت دیں؟</item>
diff --git a/res/values-uz/strings.xml b/res/values-uz/strings.xml
index 168bbf8..8b2e56e 100644
--- a/res/values-uz/strings.xml
+++ b/res/values-uz/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Tozalash"</string>
<string name="allow" msgid="8885707816848569619">"Ruxsat"</string>
<string name="deny" msgid="6040983710442068936">"Rad etish"</string>
+ <string name="add" msgid="2894574044585549298">"Kiritish"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Tanlanganni koʻrish"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other"><xliff:g id="APP_NAME_1">^1</xliff:g> ilovasiga <xliff:g id="COUNT">^2</xliff:g> ta audio faylni oʻzgartirishi uchun ruxsat berilsinmi?</item>
<item quantity="one"><xliff:g id="APP_NAME_0">^1</xliff:g> ilovasiga bu audio faylni oʻzgartirishi uchun ruxsat berilsinmi?</item>
diff --git a/res/values-vi/strings.xml b/res/values-vi/strings.xml
index 87f6201..958f633 100644
--- a/res/values-vi/strings.xml
+++ b/res/values-vi/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Xóa"</string>
<string name="allow" msgid="8885707816848569619">"Cho phép"</string>
<string name="deny" msgid="6040983710442068936">"Từ chối"</string>
+ <string name="add" msgid="2894574044585549298">"Thêm"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Đã chọn Chế độ xem"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">Cho phép <xliff:g id="APP_NAME_1">^1</xliff:g> sửa đổi <xliff:g id="COUNT">^2</xliff:g> tệp âm thanh?</item>
<item quantity="one">Cho phép <xliff:g id="APP_NAME_0">^1</xliff:g> sửa đổi tệp âm thanh này?</item>
diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml
index b1f7f7f..667b578 100644
--- a/res/values-zh-rCN/strings.xml
+++ b/res/values-zh-rCN/strings.xml
@@ -43,6 +43,9 @@
<string name="clear" msgid="5524638938415865915">"清除"</string>
<string name="allow" msgid="8885707816848569619">"允许"</string>
<string name="deny" msgid="6040983710442068936">"拒绝"</string>
+ <string name="add" msgid="2894574044585549298">"添加"</string>
+ <!-- no translation found for picker_view_selected (2266031384396143883) -->
+ <skip />
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">要允许<xliff:g id="APP_NAME_1">^1</xliff:g>修改这 <xliff:g id="COUNT">^2</xliff:g> 个音频文件吗?</item>
<item quantity="one">要允许<xliff:g id="APP_NAME_0">^1</xliff:g>修改这个音频文件吗?</item>
diff --git a/res/values-zh-rHK/strings.xml b/res/values-zh-rHK/strings.xml
index 51d8c9e..e7c9003 100644
--- a/res/values-zh-rHK/strings.xml
+++ b/res/values-zh-rHK/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"清除"</string>
<string name="allow" msgid="8885707816848569619">"允許"</string>
<string name="deny" msgid="6040983710442068936">"拒絕"</string>
+ <string name="add" msgid="2894574044585549298">"新增"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"查看所選項目"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">允許 <xliff:g id="APP_NAME_1">^1</xliff:g> 修改 <xliff:g id="COUNT">^2</xliff:g> 部影片嗎?</item>
<item quantity="one">允許 <xliff:g id="APP_NAME_0">^1</xliff:g> 修改此影片嗎?</item>
diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml
index a67a29e..cef4123 100644
--- a/res/values-zh-rTW/strings.xml
+++ b/res/values-zh-rTW/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"清除"</string>
<string name="allow" msgid="8885707816848569619">"允許"</string>
<string name="deny" msgid="6040983710442068936">"拒絕"</string>
+ <string name="add" msgid="2894574044585549298">"新增"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"查看所選項目"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="other">要允許「<xliff:g id="APP_NAME_1">^1</xliff:g>」修改這 <xliff:g id="COUNT">^2</xliff:g> 個音訊檔案嗎?</item>
<item quantity="one">要允許「<xliff:g id="APP_NAME_0">^1</xliff:g>」修改這個音訊檔案嗎?</item>
diff --git a/res/values-zu/strings.xml b/res/values-zu/strings.xml
index 36d01de..2e1131b 100644
--- a/res/values-zu/strings.xml
+++ b/res/values-zu/strings.xml
@@ -43,6 +43,8 @@
<string name="clear" msgid="5524638938415865915">"Sula"</string>
<string name="allow" msgid="8885707816848569619">"Vumela"</string>
<string name="deny" msgid="6040983710442068936">"Nqaba"</string>
+ <string name="add" msgid="2894574044585549298">"Engeza"</string>
+ <string name="picker_view_selected" msgid="2266031384396143883">"Ukubuka kukhethiwe"</string>
<plurals name="permission_write_audio" formatted="false" msgid="8914759422381305478">
<item quantity="one">Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amafayela omsindo angu-<xliff:g id="COUNT">^2</xliff:g>?</item>
<item quantity="other">Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amafayela omsindo angu-<xliff:g id="COUNT">^2</xliff:g>?</item>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index ed0c6c2..e593c25 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -17,4 +17,17 @@
<resources>
<color name="thumb_gray_color">#1F000000</color>
<color name="clear_cache_icon_color">#5F6368</color>
+
+ <!-- PhotoPicker -->
+ <color name="picker_default_white">@android:color/white</color>
+
+ <!-- PhotoPicker photo grid -->
+ <color name="picker_primary_color">#1A73E8</color>
+ <color name="picker_background_color">@android:color/white</color>
+ <color name="picker_highlight_color">#E8F0FE</color>
+
+ <!-- PhotoPicker Preview -->
+ <color name="preview_default_blue">#8AB4F8</color>
+ <color name="preview_default_grey">#202124</color>
+ <color name="preview_default_black">@android:color/black</color>
</resources>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index c3bdf8f..92812ee 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -19,4 +19,24 @@
<dimen name="permission_thumb_size">64dp</dimen>
<dimen name="permission_thumb_margin">6dp</dimen>
<dimen name="dialog_space">20dp</dimen>
+
+ <!-- PhotoPicker -->
+ <dimen name="picker_photo_size">118dp</dimen>
+ <dimen name="picker_bottom_bar_size">56dp</dimen>
+ <dimen name="picker_bottom_bar_horizontal_gap">16dp</dimen>
+ <dimen name="picker_bottom_bar_vertical_gap">10dp</dimen>
+ <dimen name="picker_bottom_bar_elevation">8dp</dimen>
+ <dimen name="picker_item_check_size">24dp</dimen>
+ <dimen name="picker_item_check_margin">6dp</dimen>
+ <dimen name="picker_item_badge_margin">5dp</dimen>
+ <dimen name="picker_item_badge_text_margin">3dp</dimen>
+ <dimen name="picker_item_badge_text_size">10dp</dimen>
+
+ <!-- PhotoPicker Preview -->
+ <dimen name="preview_buttons_margin_horizontal">16dp</dimen>
+ <dimen name="preview_buttons_margin_bottom">10dp</dimen>
+ <dimen name="preview_deselect_padding_start">2dp</dimen>
+ <!-- PhotoPicker Preview text -->
+ <dimen name="preview_add_text_size">14sp</dimen>
+ <dimen name="preview_deselect_text_size">16sp</dimen>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 98c885b..be3c0a5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -79,6 +79,18 @@
<!-- Deny dialog action text. [CHAR LIMIT=30] -->
<string name="deny">Deny</string>
+ <!-- Add button for PhotoPicker. [CHAR LIMIT=30] -->
+ <string name="add">Add</string>
+
+ <!-- Deselect button for PhotoPicker. [CHAR LIMIT=30] -->
+ <string name="deselect">Deselect</string>
+
+ <!-- Select button for PhotoPicker. [CHAR LIMIT=30] -->
+ <string name="select">Select</string>
+
+ <!-- PhotoPicker view selected action text. [CHAR LIMIT=80] -->
+ <string name="picker_view_selected">View selected</string>
+
<!-- ========================= BEGIN AUTO-GENERATED BY gen_strings.py ========================= -->
<!-- ========================= WRITE STRINGS ========================= -->
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 5f1e662..1db1478 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -49,4 +49,18 @@
parent="@android:style/TextAppearance.DeviceDefault.DialogWindowTitle">
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
+
+ <style name="PickerDefaultTheme" parent="@style/Theme.MaterialComponents.DayNight.NoActionBar">
+ <!-- Color section -->
+ <item name="android:colorBackground">@color/picker_background_color</item>
+ <item name="android:colorAccent">@color/picker_primary_color</item>
+
+ <!-- System | Widget section -->
+ <item name="android:statusBarColor">?android:colorBackground</item>
+ <item name="android:navigationBarColor">?android:colorBackground</item>
+ <item name="android:windowBackground">?android:colorBackground</item>
+ <item name="android:windowLightStatusBar">true</item>
+ <item name="android:windowLightNavigationBar">true</item>
+ </style>
+
</resources>
diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java
index c958721..798614f 100644
--- a/src/com/android/providers/media/MediaDocumentsProvider.java
+++ b/src/com/android/providers/media/MediaDocumentsProvider.java
@@ -202,7 +202,7 @@
* When underlying provider is ready, we kick off a notification of roots
* changed so they can be refreshed.
*/
- static void onMediaStoreReady(Context context, String volumeName) {
+ static void onMediaStoreReady(Context context) {
sMediaStoreReady = true;
notifyRootsChanged(context);
}
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 7a1c5d0..c03045c 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -82,6 +82,7 @@
import static com.android.providers.media.util.Logging.LOGV;
import static com.android.providers.media.util.Logging.TAG;
+import android.annotation.IntDef;
import android.app.AppOpsManager;
import android.app.AppOpsManager.OnOpActiveChangedListener;
import android.app.AppOpsManager.OnOpChangedListener;
@@ -209,6 +210,7 @@
import com.android.providers.media.util.Metrics;
import com.android.providers.media.util.MimeUtils;
import com.android.providers.media.util.PermissionUtils;
+import com.android.providers.media.util.Preconditions;
import com.android.providers.media.util.SQLiteQueryBuilder;
import com.android.providers.media.util.UserCache;
import com.android.providers.media.util.XmpInterface;
@@ -223,6 +225,8 @@
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
@@ -2421,9 +2425,8 @@
final String newMimeType = MimeUtils.resolveMimeType(new File(newPath));
final String oldMimeType = MimeUtils.resolveMimeType(new File(oldPath));
final boolean isSameMimeType = newMimeType.equalsIgnoreCase(oldMimeType);
- final ContentValues contentValues = getContentValuesForFuseRename(newPath, newMimeType,
+ ContentValues contentValues = getContentValuesForFuseRename(newPath, newMimeType,
wasHidden, isHidden, isSameMimeType);
-
if (!updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues)) {
if (!bypassRestrictions) {
// Check for other URI format grants for oldPath only. Check right before
@@ -2637,13 +2640,10 @@
}
final int type;
- final boolean forWrite;
if ((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) {
type = TYPE_UPDATE;
- forWrite = true;
} else {
type = TYPE_QUERY;
- forWrite = false;
}
final SQLiteQueryBuilder qb = getQueryBuilder(type, table, uri, Bundle.EMPTY, null);
@@ -7365,7 +7365,7 @@
final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), itemOwner);
if (hasOwner && !callerIsOwner) {
throw new IllegalStateException(
- "Only owner is able to interact with pending item " + item);
+ "Only owner is able to interact with pending/trashed item " + item);
}
}
@@ -7870,6 +7870,8 @@
/**
* Calculates the ranges that need to be redacted for the given file and user that wants to
* access the file.
+ * Note: This method assumes that the caller of this function has already done permission checks
+ * for the uid to access this path.
*
* @param uid UID of the package wanting to access the file
* @param path File path
@@ -7919,16 +7921,20 @@
final Uri contentUri = FileUtils.getContentUriForPath(path);
final String[] projection = new String[]{
- MediaColumns.OWNER_PACKAGE_NAME, MediaColumns._ID };
+ MediaColumns.OWNER_PACKAGE_NAME, MediaColumns._ID , FileColumns.MEDIA_TYPE};
final String selection = MediaColumns.DATA + "=?";
final String[] selectionArgs = new String[]{path};
final String ownerPackageName;
- final Uri item;
- try (final Cursor c = queryForSingleItem(contentUri, projection, selection,
- selectionArgs, null)) {
+ final int id;
+ final int mediaType;
+ // Query as MediaProvider as non-RES apps will result in FileNotFoundException.
+ // Note: The caller uid already has passed permission checks to access this file.
+ try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection,
+ selection, selectionArgs, null)) {
c.moveToFirst();
ownerPackageName = c.getString(0);
- item = ContentUris.withAppendedId(contentUri, /*item id*/ c.getInt(1));
+ id = c.getInt(1);
+ mediaType = c.getInt(2);
} catch (FileNotFoundException e) {
// Ideally, this shouldn't happen unless the file was deleted after we checked its
// existence and before we get to the redaction logic here. In this case we throw
@@ -7941,14 +7947,23 @@
final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(),
ownerPackageName);
+ // Do not redact if the caller is the owner
if (callerIsOwner) {
return new long[0];
}
- final boolean callerHasUriPermission = getContext().checkUriPermission(
- item, mCallingIdentity.get().pid, mCallingIdentity.get().uid,
+ // Do not redact if the caller has write uri permission granted on the file.
+ final Uri fileUri = ContentUris.withAppendedId(contentUri, id);
+ boolean callerHasWriteUriPermission = getContext().checkUriPermission(
+ fileUri, mCallingIdentity.get().pid, mCallingIdentity.get().uid,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED;
- if (callerHasUriPermission) {
+ if (callerHasWriteUriPermission) {
+ return new long[0];
+ }
+ // Check if the caller has write access to other uri formats for the same file.
+ callerHasWriteUriPermission = getOtherUriGrantsForPath(path, mediaType,
+ Long.toString(id), /* forWrite */ true) != null;
+ if (callerHasWriteUriPermission) {
return new long[0];
}
@@ -8106,12 +8121,15 @@
MediaColumns._ID,
MediaColumns.OWNER_PACKAGE_NAME,
MediaColumns.IS_PENDING,
- FileColumns.MEDIA_TYPE};
+ FileColumns.MEDIA_TYPE,
+ MediaColumns.IS_TRASHED
+ };
final String selection = MediaColumns.DATA + "=?";
final String[] selectionArgs = new String[]{path};
final long id;
final int mediaType;
final boolean isPending;
+ final boolean isTrashed;
String ownerPackageName = null;
try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection,
selection,
@@ -8120,11 +8138,12 @@
ownerPackageName = c.getString(1);
isPending = c.getInt(2) != 0;
mediaType = c.getInt(3);
+ isTrashed = c.getInt(4) != 0;
}
final File file = new File(path);
Uri fileUri = MediaStore.Files.getContentUri(extractVolumeName(path), id);
// We don't check ownership for files with IS_PENDING set by FUSE
- if (isPending && !isPendingFromFuse(new File(path))) {
+ if (isTrashed || (isPending && !isPendingFromFuse(new File(path)))) {
requireOwnershipForItem(ownerPackageName, fileUri);
}
@@ -8548,80 +8567,52 @@
}
}
- /**
- * Checks if the app with the given UID is allowed to create or delete the directory with the
- * given path.
- *
- * @param path File path of the directory that the app wants to create/delete
- * @param uid UID of the app that wants to create/delete the directory
- * @param forCreate denotes whether the operation is directory creation or deletion
- * @return 0 if the operation is allowed, or the following {@code errno} values:
- * <ul>
- * <li>{@link OsConstants#EACCES} if the app tries to create/delete a dir in another app's
- * external directory, or if the calling package is a legacy app that doesn't have
- * WRITE_EXTERNAL_STORAGE permission.
- * <li>{@link OsConstants#EPERM} if the app tries to create/delete a top-level directory.
- * </ul>
- *
- * Called from JNI in jni/MediaProviderWrapper.cpp
- */
- @Keep
- public int isDirectoryCreationOrDeletionAllowedForFuse(
- @NonNull String path, int uid, boolean forCreate) {
- final LocalCallingIdentity token =
- clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
- PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
+ // These need to stay in sync with MediaProviderWrapper.cpp's DirectoryAccessRequestType enum
+ @IntDef(flag = true, prefix = { "DIRECTORY_ACCESS_FOR_" }, value = {
+ DIRECTORY_ACCESS_FOR_READ,
+ DIRECTORY_ACCESS_FOR_WRITE,
+ DIRECTORY_ACCESS_FOR_CREATE,
+ DIRECTORY_ACCESS_FOR_DELETE,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @VisibleForTesting
+ @interface DirectoryAccessType {}
- try {
- // App dirs are not indexed, so we don't create an entry for the file.
- if (isPrivatePackagePathNotAccessibleByCaller(path)) {
- Log.e(TAG, "Can't modify another app's external directory!");
- return OsConstants.EACCES;
- }
+ @VisibleForTesting
+ static final int DIRECTORY_ACCESS_FOR_READ = 1;
- if (shouldBypassFuseRestrictions(/*forWrite*/ true, path)) {
- return 0;
- }
- // Legacy apps that made is this far don't have the right storage permission and hence
- // are not allowed to access anything other than their external app directory
- if (isCallingPackageRequestingLegacy()) {
- return OsConstants.EACCES;
- }
+ @VisibleForTesting
+ static final int DIRECTORY_ACCESS_FOR_WRITE = 2;
- final String[] relativePath = sanitizePath(extractRelativePath(path));
- final boolean isTopLevelDir =
- relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
- if (isTopLevelDir) {
- // We allow creating the default top level directories only, all other operations on
- // top level directories are not allowed.
- if (forCreate && FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
- return 0;
- }
- Log.e(TAG,
- "Creating a non-default top level directory or deleting an existing"
- + " one is not allowed!");
- return OsConstants.EPERM;
- }
- return 0;
- } finally {
- restoreLocalCallingIdentity(token);
- }
- }
+ @VisibleForTesting
+ static final int DIRECTORY_ACCESS_FOR_CREATE = 3;
+
+ @VisibleForTesting
+ static final int DIRECTORY_ACCESS_FOR_DELETE = 4;
/**
- * Checks whether the app with the given UID is allowed to open the directory denoted by the
+ * Checks whether the app with the given UID is allowed to access the directory denoted by the
* given path.
*
* @param path directory's path
* @param uid UID of the requesting app
- * @return 0 if it's allowed to open the diretory, {@link OsConstants#EACCES} if the calling
- * package is a legacy app that doesn't have READ_EXTERNAL_STORAGE permission,
- * {@link OsConstants#ENOENT} otherwise.
+ * @param accessType type of access being requested - eg {@link
+ * MediaProvider#DIRECTORY_ACCESS_FOR_READ}
+ * @return 0 if it's allowed to access the directory, {@link OsConstants#ENOENT} for attempts
+ * to access a private package path in Android/data or Android/obb the caller doesn't have
+ * access to, and otherwise {@link OsConstants#EACCES} if the calling package is a legacy app
+ * that doesn't have READ_EXTERNAL_STORAGE permission or for other invalid attempts to access
+ * Android/data or Android/obb dirs.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
- public int isOpendirAllowedForFuse(@NonNull String path, int uid, boolean forWrite) {
+ public int isDirAccessAllowedForFuse(@NonNull String path, int uid,
+ @DirectoryAccessType int accessType) {
+ Preconditions.checkArgumentInRange(accessType, 1, DIRECTORY_ACCESS_FOR_DELETE,
+ "accessType");
+
+ final boolean forRead = accessType == DIRECTORY_ACCESS_FOR_READ;
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
@@ -8634,14 +8625,16 @@
return OsConstants.ENOENT;
}
- if (shouldBypassFuseRestrictions(forWrite, path)) {
+ if (shouldBypassFuseRestrictions(/* forWrite= */ !forRead, path)) {
return 0;
}
- // Do not allow apps to open Android/data or Android/obb dirs.
- // On primary volumes, apps that get special access to these directories get it via
- // mount views of lowerfs. On secondary volumes, such apps would return early from
- // shouldBypassFuseRestrictions above.
+ // Do not allow apps that reach this point to access Android/data or Android/obb dirs.
+ // Creation should be via getContext().getExternalFilesDir() etc methods.
+ // Reads and writes on primary volumes should be via mount views of lowerfs for apps
+ // that get special access to these directories.
+ // Reads and writes on secondary volumes would be provided via an early return from
+ // shouldBypassFuseRestrictions above (again just for apps with special access).
if (isDataOrObbPath(path)) {
return OsConstants.EACCES;
}
@@ -8653,22 +8646,34 @@
}
// This is a non-legacy app. Rest of the directories are generally writable
// except for non-default top-level directories.
- if (forWrite) {
+ if (!forRead) {
final String[] relativePath = sanitizePath(extractRelativePath(path));
if (relativePath.length == 0) {
- Log.e(TAG, "Directoy write not allowed on invalid relative path for " + path);
+ Log.e(TAG,
+ "Directory update not allowed on invalid relative path for " + path);
return OsConstants.EPERM;
}
final boolean isTopLevelDir =
relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
if (isTopLevelDir) {
- if (FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
- return 0;
- } else {
- Log.e(TAG,
- "Writing to a non-default top level directory is not allowed!");
+ // We don't allow deletion of any top-level folders
+ if (accessType == DIRECTORY_ACCESS_FOR_DELETE) {
+ Log.e(TAG, "Deleting top level directories are not allowed!");
return OsConstants.EACCES;
}
+
+ // We allow creating or writing to default top-level folders, but we don't
+ // allow creation or writing to non-default top-level folders.
+ if ((accessType == DIRECTORY_ACCESS_FOR_CREATE
+ || accessType == DIRECTORY_ACCESS_FOR_WRITE)
+ && FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
+ return 0;
+ }
+
+ Log.e(TAG,
+ "Creating or writing to a non-default top level directory is not "
+ + "allowed!");
+ return OsConstants.EACCES;
}
}
@@ -9334,7 +9339,7 @@
// We just finished the database operation above, we know that
// it's ready to answer queries, so notify our DocumentProvider
// so it can answer queries without risking ANR
- MediaDocumentsProvider.onMediaStoreReady(getContext(), volumeName);
+ MediaDocumentsProvider.onMediaStoreReady(getContext());
});
}
return uri;
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
index 66182bc..3d32e9b 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * 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.
@@ -16,116 +16,61 @@
package com.android.providers.media.photopicker;
+import static com.android.providers.media.photopicker.data.PickerResult.getPickerResponseIntent;
+
import android.app.Activity;
-import android.content.ClipData;
-import android.content.ClipDescription;
-import android.content.ContentUris;
import android.content.Intent;
-import android.database.Cursor;
-import android.net.Uri;
import android.os.Bundle;
-import android.provider.MediaStore;
-import android.widget.ArrayAdapter;
-import android.widget.Button;
-import android.widget.ListView;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import androidx.lifecycle.ViewModelProvider;
import com.android.providers.media.R;
+import com.android.providers.media.photopicker.data.model.Item;
+import com.android.providers.media.photopicker.ui.PhotosTabFragment;
+import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
-import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
/**
* Photo Picker allows users to choose one or more photos and/or videos to share with an app. The
* app does not get access to all photos/videos.
*/
-public class PhotoPickerActivity extends Activity {
+public class PhotoPickerActivity extends AppCompatActivity {
- public static final String TAG = "PhotoPickerActivity";
+ private PickerViewModel mPickerViewModel;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_photo_picker);
- // TODO(b/168001592) Change layout to show photos & options.
- setContentView(R.layout.photo_picker);
- Button button = findViewById(R.id.button);
- button.setOnClickListener(v -> respondEmpty());
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ // TODO (b/185801192): remove this and add tabs Photos and Albums
+ getSupportActionBar().setTitle("Photos & Videos");
- // TODO(b/168001592) Handle multiple selection option.
+ final boolean canSelectMultiple = getIntent().getBooleanExtra(
+ Intent.EXTRA_ALLOW_MULTIPLE, false);
- // TODO(b/168001592) Filter using given mime type.
+ mPickerViewModel = new ViewModelProvider(this).get(PickerViewModel.class);
+ mPickerViewModel.setSelectMultiple(canSelectMultiple);
- // TODO(b/168001592) Show a photo grid instead of ListView.
- ListView photosList = findViewById(R.id.names_list);
- ArrayAdapter<PhotoEntry> photosAdapter = new ArrayAdapter<>(
- this, android.R.layout.simple_list_item_1);
- photosList.setAdapter(photosAdapter);
- // Clicking an item in the list returns its URI for now.
- photosList.setOnItemClickListener((parent, view, position, id) -> {
- respondPhoto(photosAdapter.getItem(position));
- });
-
- // Show the list of photo names for now.
- ImmutableList.Builder<PhotoEntry> imageRowsBuilder = ImmutableList.builder();
- String[] projection = new String[] {
- MediaStore.MediaColumns._ID,
- MediaStore.MediaColumns.DISPLAY_NAME
- };
- // TODO(b/168001592) call query() from worker thread.
- Cursor cursor = getApplicationContext().getContentResolver().query(
- MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
- projection, null, null);
- int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
- int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
- // TODO(b/168001592) Use better image loading (e.g. use paging, glide).
- while (cursor.moveToNext()) {
- imageRowsBuilder.add(
- new PhotoEntry(cursor.getLong(idColumn), cursor.getString(nameColumn)));
+ // only add the fragment when the activity is created at first time
+ if (savedInstanceState == null) {
+ getSupportFragmentManager().beginTransaction()
+ .setReorderingAllowed(true)
+ .add(R.id.fragment_container, PhotosTabFragment.class, null)
+ .commitNow();
}
- photosAdapter.addAll(imageRowsBuilder.build());
}
- private void respondPhoto(PhotoEntry photoEntry) {
- Uri contentUri = ContentUris.withAppendedId(
- MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
- photoEntry.id);
-
- Intent response = new Intent();
- // TODO(b/168001592) Confirm if this flag is enough to grant the access we want.
- response.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-
- // TODO(b/168001592) Use a better label and accurate mime types.
- if (getIntent().getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) {
- ClipDescription clipDescription = new ClipDescription(
- "Photo Picker ClipData",
- new String[]{"image/*", "video/*"});
- ClipData clipData = new ClipData(clipDescription, new ClipData.Item(contentUri));
- response.setClipData(clipData);
- } else {
- response.setData(contentUri);
- }
-
- setResult(Activity.RESULT_OK, response);
+ public void setResultAndFinishSelf() {
+ final List<Item> selectedItemList = new ArrayList<>(
+ mPickerViewModel.getSelectedItems().getValue().values());
+ setResult(Activity.RESULT_OK, getPickerResponseIntent(this, selectedItemList));
finish();
}
-
-
- private void respondEmpty() {
- setResult(Activity.RESULT_OK);
- finish();
- }
-
- private static class PhotoEntry {
- private long id;
- private String name;
-
- PhotoEntry(long id, String name) {
- this.id = id;
- this.name = name;
- }
-
- @Override
- public String toString() {
- return name;
- }
- }
-}
+}
\ No newline at end of file
diff --git a/src/com/android/providers/media/photopicker/data/ItemsProvider.java b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
new file mode 100644
index 0000000..84f53e0
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
@@ -0,0 +1,112 @@
+/*
+ * 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.data;
+
+import android.annotation.Nullable;
+import android.content.ContentProvider;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore;
+import android.provider.MediaStore.MediaColumns;
+
+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;
+
+/**
+ * The base class that is responsible for obtaining data from all providers and
+ * merge the data together and provide it to ViewModel.
+ */
+public class ItemsProvider {
+ private Context mContext;
+ private LocalItemsProvider mLocalItemsProvider;
+
+ public ItemsProvider(Context context) {
+ mContext = context;
+ mLocalItemsProvider = new LocalItemsProvider(mContext);
+ }
+
+ /**
+ * Returns a {@link Cursor} to all images/videos that are provided by {@link LocalItemsProvider}
+ *
+ * <p>
+ * Note: By default the returned {@link Cursor} sorts by {@link MediaColumns#DATE_TAKEN}.
+ *
+ * @param category the category of items to return, {@link Category.CategoryType} are supported.
+ * {@code null} defaults to {@link Category#CATEGORY_DEFAULT} which returns
+ * items from all categories.
+ * @param offset the offset after which to return items.
+ * @param limit the limit of number of items to return.
+ * @param mimeType the mime type of item, only {@code image/*} or {@code video/*} is an
+ * acceptable mimeType here. Any other mimeType than image/video throws error.
+ * {@code null} returns all images/videos that are scanned by
+ * {@link MediaStore}.
+ * @param userId the {@link UserId} of the user to get items as.
+ * {@code null} defaults to {@link UserId#CURRENT_USER}.
+ *
+ * @return {@link Cursor} to all images/videos on external storage that are scanned by
+ * {@link MediaStore} based on params passed, or {@code null} if there are no such
+ * images/videos. The Cursor for each item would contain {@link Item.ItemColumns}
+ *
+ * @throws IllegalArgumentException thrown if unsupported values for {@code mimeType},
+ * {@code category} is passed.
+ * @throws IllegalStateException thrown if unsupported value for {@code userId} is passed.
+ */
+ @Nullable
+ public Cursor getItems(@Nullable String category, int offset, int limit,
+ @Nullable String mimeType, @Nullable UserId userId) throws IllegalArgumentException,
+ IllegalStateException {
+ return mLocalItemsProvider.getItems(category, offset, limit, mimeType, userId);
+ }
+
+ /**
+ * Returns a {@link Cursor} containing basic information (as columns:
+ * {@link Category.CategoryColumns}) for non-empty categories.
+ * A {@link Category} is a collection of items (images/videos) that are put into different
+ * buckets based on various criteria as defined in {@link Category.CategoryType}.
+ * This includes a list of constant categories for LocalItemsProvider: {@link Category} contains
+ * a constant list of local categories supported in v0.
+ *
+ * @param userId the {@link UserId} of the user to get categories as.
+ * {@code null} defaults to {@link UserId#CURRENT_USER}.
+ *
+ * @return {@link Cursor} for each category would contain the following columns in
+ * their relative order:
+ * categoryName: {@link Category.CategoryColumns#NAME} The name of the category,
+ * categoryCoverUri: {@link Category.CategoryColumns#COVER_URI} The Uri for the cover of
+ * the category. By default this will be the most recent image/video in that
+ * category,
+ * categoryNumberOfItems: {@link Category.CategoryColumns#NUMBER_OF_ITEMS} number of image/video
+ * items in the category,
+ *
+ * @throws IllegalStateException thrown if unsupported value for {@code userId} is passed.
+ */
+ @Nullable
+ public Cursor getCategories(@Nullable UserId userId) {
+ return mLocalItemsProvider.getCategories(userId);
+ }
+
+ public static Uri getItemsUri(long id, UserId userId) {
+ final Uri uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL, id);
+ if (userId.equals(UserId.CURRENT_USER)) {
+ return uri;
+ } else {
+ return ContentProvider.createContentUriForUser(uri, userId.getUserHandle());
+ }
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/LocalItemsProvider.java b/src/com/android/providers/media/photopicker/data/LocalItemsProvider.java
new file mode 100644
index 0000000..ed42ff4
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/LocalItemsProvider.java
@@ -0,0 +1,251 @@
+/*
+ * 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.data;
+
+import static com.android.providers.media.util.MimeUtils.isImageMimeType;
+import static com.android.providers.media.util.MimeUtils.isVideoMimeType;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Files.FileColumns;
+import android.provider.MediaStore.MediaColumns;
+
+import com.android.providers.media.photopicker.data.model.Category;
+import com.android.providers.media.photopicker.data.model.Category.CategoryColumns;
+import com.android.providers.media.photopicker.data.model.Item.ItemColumns;
+import com.android.providers.media.photopicker.data.model.UserId;
+
+import java.util.List;
+
+/**
+ * Provides local image and video items from {@link MediaStore} collection to the Photo Picker.
+ * <p>
+ * This class is responsible for fetching data from {@link MediaStore} collection and
+ * providing the data to the data model for Photo Picker.
+ * This class will obtain information about images and videos stored on the device by querying
+ * {@link MediaStore} database.
+ * <p>
+ * This class *only* provides data on the images and videos that are stored on external storage.
+ *
+ */
+public class LocalItemsProvider {
+
+ private static final String IMAGES_VIDEOS_WHERE_CLAUSE = "( " +
+ FileColumns.MEDIA_TYPE + " = " + FileColumns.MEDIA_TYPE_IMAGE + " OR "
+ + FileColumns.MEDIA_TYPE + " = " + FileColumns.MEDIA_TYPE_VIDEO + " )";
+
+ private final Context mContext;
+
+ public LocalItemsProvider(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Returns a {@link Cursor} to all images/videos that are scanned by {@link MediaStore}
+ * based on the param passed for {@code categoryType}, {@code offset}, {@code limit},
+ * {@code mimeType} and {@code userId}.
+ *
+ * <p>
+ * By default the returned {@link Cursor} sorts by latest {@link MediaColumns#DATE_TAKEN}.
+ *
+ * @param category the category of items to return, {@link Category.CategoryType} are supported.
+ * {@code null} defaults to {@link Category#CATEGORY_DEFAULT} which returns
+ * items from all categories.
+ * @param offset the offset after which to return items.
+ * @param limit the limit of number of items to return.
+ * @param mimeType the mime type of item, only {@code image/*} or {@code video/*} is an
+ * acceptable mimeType here. Any other mimeType than image/video throws error.
+ * {@code null} returns all images/videos that are scanned by
+ * {@link MediaStore}.
+ * @param userId the {@link UserId} of the user to get items as.
+ * {@code null} defaults to {@link UserId#CURRENT_USER}
+ *
+ * @return {@link Cursor} to all images/videos on external storage that are scanned by
+ * {@link MediaStore} based on params passed, or {@code null} if there are no such
+ * images/videos. The Cursor for each item would contain {@link ItemColumns}
+ *
+ * @throws IllegalArgumentException thrown if unsupported values for {@code mimeType},
+ * {@code category} is passed.
+ * @throws IllegalStateException thrown if unsupported value for {@code userId} is passed.
+ *
+ */
+ @Nullable
+ public Cursor getItems(@Nullable @Category.CategoryType String category, int offset,
+ int limit, @Nullable String mimeType, @Nullable UserId userId)
+ throws IllegalArgumentException, IllegalStateException {
+ if (userId == null) {
+ userId = UserId.CURRENT_USER;
+ }
+
+ return getItemsInternal(category, offset, limit, mimeType, userId);
+ }
+
+ @Nullable
+ private Cursor getItemsInternal(@Nullable @Category.CategoryType String category,
+ int offset, int limit, @Nullable String mimeType,
+ @NonNull UserId userId) throws IllegalArgumentException, IllegalStateException {
+ // 1. Validate incoming params
+ if (category != null && Category.isValidCategory(category)) {
+ throw new IllegalArgumentException("LocalItemsProvider does not support the given"
+ + " category: " + category);
+ }
+
+ if (mimeType != null && !isMimeTypeImageVideo(mimeType)) {
+ throw new IllegalArgumentException("LocalItemsProvider does not support the given"
+ + " mimeType: " + mimeType);
+ }
+
+ // 2. Create args to query MediaStore
+ String selection = null;
+ String[] selectionArgs = null;
+
+ if (category != null && Category.getWhereClauseForCategory(category) != null) {
+ selection = Category.getWhereClauseForCategory(category);
+ }
+
+ if (mimeType != null && isMimeTypeImageVideo(mimeType)) {
+ if (selection != null) {
+ selection += " AND ";
+ } else {
+ selection = "";
+ }
+ selection += MediaColumns.MIME_TYPE + " LIKE ? ";
+ selectionArgs = new String[] {replaceMatchAnyChar(mimeType)};
+ }
+
+ final String[] projection = ItemColumns.ALL_COLUMNS_LIST.toArray(new String[0]);
+ // 3. Query MediaStore and return
+ return queryMediaStore(projection, selection, selectionArgs, offset, limit, userId);
+ }
+
+ /**
+ * Returns a {@link Cursor} to all non-empty categories in which images/videos (that are
+ * scanned by {@link MediaStore}) are put in buckets based on certain criteria.
+ * This includes a list of constant categories for LocalItemsProvider: {@link Category} contains
+ * a constant list of local categories we have on-device and want to support for v0.
+ *
+ * @param userId the {@link UserId} of the user to get categories as.
+ * {@code null} defaults to {@link UserId#CURRENT_USER}.
+ *
+ * @return {@link Cursor} for each category would contain the following columns in
+ * their relative order:
+ * categoryName: {@link CategoryColumns#NAME} The name of the category,
+ * categoryCoverUri: {@link CategoryColumns#COVER_URI} The Uri for the cover of
+ * the category. By default this will be the most recent image/video in that
+ * category,
+ * categoryNumberOfItems: {@link CategoryColumns#NUMBER_OF_ITEMS} number of image/video items
+ * in the category,
+ */
+ @Nullable
+ public Cursor getCategories(@Nullable UserId userId) {
+ if (userId == null) {
+ userId = UserId.CURRENT_USER;
+ }
+ return buildCategoriesCursor(Category.CATEGORIES_LIST, userId);
+ }
+
+ private Cursor buildCategoriesCursor(List<String> categories, @NonNull UserId userId) {
+ MatrixCursor c = new MatrixCursor(CategoryColumns.getAllColumns());
+
+ for (String category: categories) {
+ String[] categoryRow = getCategoryColumns(category, userId);
+ if (categoryRow != null) {
+ c.addRow(categoryRow);
+ }
+ }
+
+ return c;
+ }
+
+ private String[] getCategoryColumns(@Category.CategoryType String category,
+ @NonNull UserId userId) throws IllegalArgumentException, IllegalStateException {
+ if (!Category.isValidCategory(category)) {
+ throw new IllegalArgumentException("Category type not supported");
+ }
+ final String whereClause = Category.getWhereClauseForCategory(category);
+ final String[] projection = new String[] {
+ MediaColumns._ID
+ };
+ Cursor c = queryMediaStore(projection, whereClause, null, 0, -1, userId);
+ // Send null if the cursor is null or cursor size is empty
+ if (c == null || !c.moveToFirst()) {
+ return null;
+ }
+
+ return new String[] {
+ category,
+ String.valueOf(getMediaStoreUriForItem(c.getLong(0))),
+ String.valueOf(c.getCount())
+ };
+ }
+
+ @Nullable
+ private Cursor queryMediaStore(@NonNull String[] projection,
+ @Nullable String extraSelection, @Nullable String[] extraSelectionArgs, int offset,
+ int limit, @NonNull UserId userId) throws IllegalStateException {
+ final Uri contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL);
+
+ String selection = IMAGES_VIDEOS_WHERE_CLAUSE;
+ String[] selectionArgs = null;
+
+ if (extraSelection != null) {
+ selection += " AND " + extraSelection;
+ }
+ if (extraSelectionArgs != null) {
+ selectionArgs = extraSelectionArgs;
+ }
+
+ try (ContentProviderClient client = userId.getContentResolver(mContext)
+ .acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) {
+ Bundle extras = new Bundle();
+ extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection);
+ extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs);
+ extras.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
+ MediaColumns.DATE_TAKEN + " DESC");
+ extras.putInt(ContentResolver.QUERY_ARG_OFFSET, offset);
+ if (limit != -1) {
+ extras.putInt(ContentResolver.QUERY_ARG_LIMIT, limit);
+ }
+
+ return client.query(contentUri, projection, extras, null);
+ } catch (RemoteException ignored) {
+ // Do nothing, return null.
+ }
+ return null;
+ }
+
+ private static Uri getMediaStoreUriForItem(long id) {
+ return MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL, id);
+ }
+
+ private static boolean isMimeTypeImageVideo(@NonNull String mimeType) {
+ return isImageMimeType(mimeType) || isVideoMimeType(mimeType);
+ }
+
+ private static String replaceMatchAnyChar(@NonNull String mimeType) {
+ return mimeType.replace('*', '%');
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/PickerResult.java b/src/com/android/providers/media/photopicker/data/PickerResult.java
new file mode 100644
index 0000000..feed174
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/PickerResult.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.data;
+
+import android.content.ClipData;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.MediaStore;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import com.android.modules.utils.build.SdkLevel;
+import com.android.providers.media.photopicker.data.model.Item;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class is responsible for returning result to the caller of the PhotoPicker.
+ */
+public class PickerResult {
+
+ /**
+ * @return {@code Intent} which contains Uri that has been granted access on.
+ */
+ @NonNull
+ public static Intent getPickerResponseIntent(@NonNull Context context,
+ @NonNull List<Item> selectedItems) {
+ // 1. Get mediaStore Uris corresponding to the selected items
+ ArrayList<Uri> selectedUris = getUrisFromItems(selectedItems);
+
+ // 2. Get redacted Uris for all selected items. We grant read access on redacted Uris for
+ // initial release of the photo picker.
+ ArrayList<Uri> redactedUris = new ArrayList<>(getRedactedUri(
+ context.getContentResolver(), selectedUris));
+
+ // 3. Grant read access to redacted Uris and return
+ Intent intent = new Intent();
+ final int size = redactedUris.size();
+ if (size == 1) {
+ intent.setData(redactedUris.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(redactedUris.get(0)));
+ for (int i = 1; i < size; i++) {
+ clipData.addItem(new ClipData.Item(redactedUris.get(i)));
+ }
+ intent.setClipData(clipData);
+ }
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ return intent;
+ }
+
+ private static List<Uri> getRedactedUri(ContentResolver contentResolver, List<Uri> uris) {
+ if (SdkLevel.isAtLeastS()) {
+ return getRedactedUriFromMediaStoreAPI(contentResolver, uris);
+ } else {
+ // TODO (b/168783994): directly call redacted uri code logic or explore other solution.
+ // This will be addressed in a follow up CL.
+ return new ArrayList<>();
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ private static List<Uri> getRedactedUriFromMediaStoreAPI(ContentResolver contentResolver,
+ List<Uri> uris) {
+ return MediaStore.getRedactedUri(contentResolver, uris);
+ }
+
+ /**
+ * Returns list of {@link MediaStore} Uris corresponding to each {@link Item}
+ *
+ * @param ItemList list of Item for which we return uri list.
+ */
+ @NonNull
+ private static ArrayList<Uri> getUrisFromItems(@NonNull List<Item> ItemList) {
+ ArrayList<Uri> uris = new ArrayList<>();
+ for (Item item : ItemList) {
+ uris.add(item.getContentUri());
+ }
+
+ return uris;
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/UserIdManager.java b/src/com/android/providers/media/photopicker/data/UserIdManager.java
new file mode 100644
index 0000000..5f64ed1
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/UserIdManager.java
@@ -0,0 +1,230 @@
+/*
+ * 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.data;
+
+import static androidx.core.util.Preconditions.checkNotNull;
+
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.providers.media.photopicker.data.model.UserId;
+
+import java.util.List;
+
+/**
+ * Interface to query user ids {@link UserId}
+ */
+public interface UserIdManager {
+
+ /**
+ * Whether there are more than 1 user profiles associated with the current user.
+ * @return
+ */
+ boolean isMultiUserProfiles();
+
+ /**
+ * Returns the personal user profile id iff there are at least 2 user profiles for current
+ * user. Otherwise, returns null.
+ */
+ @Nullable
+ UserId getPersonalUserId();
+
+ /**
+ * Returns the managed user profile id iff there are at least 2 user profiles for current user.
+ * Otherwise, returns null.
+ */
+ @Nullable
+ UserId getManagedUserId();
+
+ /**
+ * Returns the current user profile id. This can be managed user profile id, personal user
+ * profile id. If the user does not have a corresponding managed profile, then this always
+ * returns the current user.
+ */
+ @Nullable
+ UserId getCurrentUserProfileId();
+
+ void setCurrentUserProfileId(UserId userId);
+
+ /**
+ * Whether the current user is the personal user profile iff there are at least 2 user
+ * profiles for current user. Otherwise, returns false.
+ */
+ boolean isPersonalUserId();
+
+ /**
+ * Whether the current user is the managed user profile iff there are at least 2 user
+ * profiles for current user. Otherwise, returns false.
+ */
+ boolean isManagedUserId();
+
+ /**
+ * Creates an implementation of {@link UserIdManager}.
+ */
+ static UserIdManager create(Context context) {
+ return new RuntimeUserIdManager(context);
+ }
+
+ /**
+ * Implementation of {@link UserIdManager}.
+ */
+ final class RuntimeUserIdManager implements UserIdManager {
+
+ private static final String TAG = "UserIdManager";
+
+ private final Context mContext;
+ private final UserId mCurrentUser;
+
+ @GuardedBy("mLock")
+ private final Object mLock = new Object();
+ @GuardedBy("mLock")
+ private UserId mPersonalUser = null;
+ @GuardedBy("mLock")
+ private UserId mManagedUser = null;
+
+ @GuardedBy("mLock")
+ private UserId mCurrentUserProfile = null;
+
+ private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ synchronized (mLock) {
+ mPersonalUser = null;
+ mManagedUser = null;
+ setUserIds();
+ }
+ }
+ };
+
+ private RuntimeUserIdManager(Context context) {
+ this(context, UserId.CURRENT_USER);
+ }
+
+ @VisibleForTesting
+ RuntimeUserIdManager(Context context, UserId currentUser) {
+ mContext = context.getApplicationContext();
+ mCurrentUser = checkNotNull(currentUser);
+ mCurrentUserProfile = mCurrentUser;
+ setUserIds();
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_MANAGED_PROFILE_ADDED);
+ filter.addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED);
+ mContext.registerReceiver(mIntentReceiver, filter);
+ }
+
+ @Override
+ public boolean isMultiUserProfiles() {
+ synchronized (mLock) {
+ return mPersonalUser != null;
+ }
+ }
+
+ @Override
+ public UserId getPersonalUserId() {
+ synchronized (mLock) {
+ return mPersonalUser;
+ }
+ }
+
+ @Override
+ public UserId getManagedUserId() {
+ synchronized (mLock) {
+ return mManagedUser;
+ }
+ }
+
+ @Override
+ public UserId getCurrentUserProfileId() {
+ synchronized (mLock) {
+ return mCurrentUserProfile;
+ }
+ }
+
+ @Override
+ public void setCurrentUserProfileId(UserId userId) {
+ synchronized (mLock) {
+ mCurrentUserProfile = userId;
+ }
+ }
+
+ @Override
+ public boolean isPersonalUserId() {
+ return mCurrentUser.equals(getPersonalUserId());
+ }
+
+ @Override
+ public boolean isManagedUserId() {
+ return mCurrentUser.equals(getManagedUserId());
+ }
+
+ private void setUserIds() {
+ synchronized (mLock) {
+ setUserIdsInternal();
+ }
+ }
+
+ @GuardedBy("mLock")
+ private void setUserIdsInternal() {
+ UserManager userManager = mContext.getSystemService(UserManager.class);
+ if (userManager == null) {
+ Log.e(TAG, "Cannot obtain user manager");
+ return;
+ }
+
+ final List<UserHandle> userProfiles = userManager.getUserProfiles();
+ if (userProfiles.size() < 2) {
+ Log.d(TAG, "Only 1 user profile found");
+ return;
+ }
+
+ if (mCurrentUser.isManagedProfile(userManager)) {
+ final UserId managedUser = mCurrentUser;
+ final UserHandle parentUser =
+ userManager.getProfileParent(managedUser.getUserHandle());
+ if (parentUser != null) {
+ mPersonalUser = UserId.of(parentUser);
+ mManagedUser = managedUser;
+ }
+
+ } else {
+ final UserId personalUser = mCurrentUser;
+ // Check if this personal profile is a parent of any other managed profile.
+ for (UserHandle userHandle : userProfiles) {
+ if (userManager.isManagedProfile(userHandle.getIdentifier())) {
+ final UserHandle parentUser =
+ userManager.getProfileParent(userHandle);
+ if (parentUser != null &&
+ parentUser.equals(personalUser.getUserHandle())) {
+ mPersonalUser = personalUser;
+ mManagedUser = UserId.of(userHandle);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/model/Category.java b/src/com/android/providers/media/photopicker/data/model/Category.java
new file mode 100644
index 0000000..93ed67c
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/model/Category.java
@@ -0,0 +1,140 @@
+/*
+ * 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.data.model;
+
+import android.annotation.StringDef;
+import android.database.Cursor;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Files.FileColumns;
+import android.util.ArrayMap;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Defines each category (which is group of items) for the photo picker.
+ */
+public class Category {
+
+ /**
+ * Photo Picker categorises images/videos into pre-defined buckets based on various criteria
+ * (for example based on file path location items may be in {@link #CATEGORY_SCREENSHOTS} or
+ * {@link #CATEGORY_CAMERA}, based on {@link FileColumns#MEDIA_TYPE}) items may be in
+ * {@link #CATEGORY_VIDEOS}). This list is predefined for v0.
+ *
+ * TODO (b/187919236): Add Downloads/SDCard categories.
+ */
+ @StringDef(prefix = { "CATEGORY_" }, value = {
+ CATEGORY_DEFAULT,
+ CATEGORY_SCREENSHOTS,
+ CATEGORY_CAMERA,
+ CATEGORY_VIDEOS,
+ CATEGORY_FAVORITES,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface CategoryType {}
+
+ /**
+ * Includes all images/videos on device that are scanned by {@link MediaStore}.
+ */
+ public static final String CATEGORY_DEFAULT = "default";
+
+ /**
+ * Includes images that are present in the Pictures/Screenshots folder.
+ */
+ public static final String CATEGORY_SCREENSHOTS = "Screenshots";
+ private static final String SCREENSHOTS_WHERE_CLAUSE =
+ MediaStore.MediaColumns.RELATIVE_PATH + " LIKE '" +
+ Environment.DIRECTORY_PICTURES + "/" +
+ Environment.DIRECTORY_SCREENSHOTS + "/%'";
+
+ /**
+ * Includes images/videos that are present in the DCIM/Camera folder.
+ */
+ public static final String CATEGORY_CAMERA = "Camera";
+ private static final String CAMERA_WHERE_CLAUSE =
+ MediaStore.MediaColumns.RELATIVE_PATH + " LIKE '" +
+ Environment.DIRECTORY_DCIM + "/Camera/%'";
+
+ /**
+ * Includes videos only.
+ */
+ public static final String CATEGORY_VIDEOS = "Videos";
+ private static final String VIDEOS_WHERE_CLAUSE = MediaStore.Files.FileColumns.MEDIA_TYPE +
+ " = " + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO;
+
+ /**
+ * Includes images/videos that have {@link MediaStore.MediaColumns#IS_FAVORITE} set.
+ */
+ public static final String CATEGORY_FAVORITES = "Favorites";
+ // TODO (b/188053832): Do not reveal implementation detail for is_favorite,
+ // use MATCH_INCLUDE in queryArgs.
+ private static final String FAVORITES_WHERE_CLAUSE =
+ MediaStore.MediaColumns.IS_FAVORITE + " =1 ";
+
+ /**
+ * Set of {@link Cursor} columns that refer to raw filesystem paths.
+ */
+ private static final ArrayMap<String, String> sCategoryWhereClause = new ArrayMap<>();
+
+ static {
+ sCategoryWhereClause.put(CATEGORY_SCREENSHOTS, SCREENSHOTS_WHERE_CLAUSE);
+ sCategoryWhereClause.put(CATEGORY_CAMERA, CAMERA_WHERE_CLAUSE);
+ sCategoryWhereClause.put(CATEGORY_VIDEOS, VIDEOS_WHERE_CLAUSE);
+ sCategoryWhereClause.put(CATEGORY_FAVORITES, FAVORITES_WHERE_CLAUSE);
+ }
+
+ public static String getWhereClauseForCategory(@CategoryType String category) {
+ return sCategoryWhereClause.get(category);
+ }
+
+ private static String[] CATEGORIES = {
+ CATEGORY_SCREENSHOTS,
+ CATEGORY_CAMERA,
+ CATEGORY_VIDEOS,
+ CATEGORY_FAVORITES
+ };
+
+ public static List<String> CATEGORIES_LIST = Collections.unmodifiableList(
+ Arrays.asList(CATEGORIES));
+
+ public static boolean isValidCategory(String category) {
+ return CATEGORIES_LIST.contains(category);
+ }
+
+ /**
+ * Defines category columns for each category
+ */
+ public static class CategoryColumns {
+ public static String NAME = "name";
+ public static String COVER_URI = "cover_uri";
+ public static String NUMBER_OF_ITEMS = "number_of_items";
+
+ public static String[] getAllColumns() {
+ return new String[] {
+ NAME,
+ COVER_URI,
+ NUMBER_OF_ITEMS
+ };
+ }
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/model/Item.java b/src/com/android/providers/media/photopicker/data/model/Item.java
new file mode 100644
index 0000000..be2910d
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/model/Item.java
@@ -0,0 +1,197 @@
+/*
+ * 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.data.model;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.providers.media.photopicker.data.ItemsProvider;
+import com.android.providers.media.util.MimeUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Base class representing one single entity/item in the PhotoPicker.
+ */
+public class Item {
+
+ public static class ItemColumns {
+ public static String ID = MediaStore.MediaColumns._ID;
+ public static String MIME_TYPE = MediaStore.MediaColumns.MIME_TYPE;
+ public static String DISPLAY_NAME = MediaStore.MediaColumns.DISPLAY_NAME;
+ public static String VOLUME_NAME = MediaStore.MediaColumns.VOLUME_NAME;
+ public static String DATE_TAKEN = MediaStore.MediaColumns.DATE_TAKEN;
+ public static String DURATION = MediaStore.MediaColumns.DURATION;
+
+ private static final String[] ALL_COLUMNS = {
+ ID,
+ MIME_TYPE,
+ DISPLAY_NAME,
+ VOLUME_NAME,
+ DATE_TAKEN,
+ DURATION,
+ };
+ public static List<String> ALL_COLUMNS_LIST = Collections.unmodifiableList(
+ Arrays.asList(ALL_COLUMNS));
+ }
+
+ private static final String MIME_TYPE_GIF = "image/gif";
+
+ private long mId;
+ private long mDateTaken;
+ private long mDuration;
+ private String mDisplayName;
+ private String mMimeType;
+ private String mVolumeName;
+ private Uri mUri;
+ private boolean mIsImage;
+ private boolean mIsVideo;
+ private boolean mIsGif;
+
+ private Item() {}
+
+ public Item(@NonNull Cursor cursor, @NonNull UserId userId) {
+ updateFromCursor(cursor, userId);
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public boolean isImage() {
+ return mIsImage;
+ }
+
+ public boolean isVideo() {
+ return mIsVideo;
+ }
+
+ public boolean isGif() {
+ return mIsGif;
+ }
+
+ public Uri getContentUri() {
+ return mUri;
+ }
+
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public long getDuration() {
+ return mDuration;
+ }
+
+ public String getMimeType() {
+ return mMimeType;
+ }
+
+ public long getDateTaken() {
+ return mDateTaken;
+ }
+
+ public String getVolumeName() {
+ return mVolumeName;
+ }
+
+ public static Item fromCursor(Cursor cursor, UserId userId) {
+ assert(cursor != null);
+ final Item info = new Item(cursor, userId);
+ return info;
+ }
+
+ /**
+ * Update the item based on the cursor
+ * @param cursor the cursor to update the data
+ */
+ public void updateFromCursor(@NonNull Cursor cursor, @NonNull UserId userId) {
+ mId = getCursorLong(cursor, ItemColumns.ID);
+ mMimeType = getCursorString(cursor, ItemColumns.MIME_TYPE);
+ mDisplayName = getCursorString(cursor, ItemColumns.DISPLAY_NAME);
+ mDateTaken = getCursorLong(cursor, ItemColumns.DATE_TAKEN);
+ mVolumeName = getCursorString(cursor, ItemColumns.VOLUME_NAME);
+ mDuration = getCursorLong(cursor, ItemColumns.DURATION);
+
+ // TODO (b/188867567): Currently, we only has local data source,
+ // get the uri from provider
+ mUri = ItemsProvider.getItemsUri(mId, userId);
+
+ parseMimeType();
+ }
+
+ private void parseMimeType() {
+ if (MIME_TYPE_GIF.equalsIgnoreCase(mMimeType)) {
+ mIsGif = true;
+ } else if (MimeUtils.isImageMimeType(mMimeType)) {
+ mIsImage = true;
+ } else if (MimeUtils.isVideoMimeType(mMimeType)) {
+ mIsVideo = true;
+ }
+ }
+
+ @Nullable
+ private static String getCursorString(Cursor cursor, String columnName) {
+ if (cursor == null) {
+ return null;
+ }
+ final int index = cursor.getColumnIndex(columnName);
+ return (index != -1) ? cursor.getString(index) : null;
+ }
+
+ /**
+ * Missing or null values are returned as -1.
+ */
+ private static long getCursorLong(Cursor cursor, String columnName) {
+ if (cursor == null) {
+ return -1;
+ }
+
+ final int index = cursor.getColumnIndex(columnName);
+ if (index == -1) {
+ return -1;
+ }
+
+ final String value = cursor.getString(index);
+ if (value == null) {
+ return -1;
+ }
+
+ try {
+ return Long.parseLong(value);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Missing or null values are returned as 0.
+ */
+ private static int getCursorInt(Cursor cursor, String columnName) {
+ if (cursor == null) {
+ return 0;
+ }
+
+ final int index = cursor.getColumnIndex(columnName);
+ return (index != -1) ? cursor.getInt(index) : 0;
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/data/model/UserId.java b/src/com/android/providers/media/photopicker/data/model/UserId.java
new file mode 100644
index 0000000..65c505f
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/data/model/UserId.java
@@ -0,0 +1,112 @@
+/*
+ * 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.data.model;
+
+import static androidx.core.util.Preconditions.checkNotNull;
+
+import android.annotation.Nullable;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.util.Log;
+
+/**
+ * Representation of a {@link UserHandle}.
+ */
+public final class UserId {
+ // A current user represents the user of the app's process. It is mainly used for comparison.
+ public static final UserId CURRENT_USER = UserId.of(Process.myUserHandle());
+
+ private static final String TAG = "PhotoPickerUserId";
+
+ private final UserHandle mUserHandle;
+
+ private UserId(UserHandle userHandle) {
+ checkNotNull(userHandle);
+ mUserHandle = userHandle;
+ }
+
+ public UserHandle getUserHandle() {
+ return mUserHandle;
+ }
+
+ /**
+ * Returns a {@link UserId} for a given {@link UserHandle}.
+ */
+ public static UserId of(UserHandle userHandle) {
+ return new UserId(userHandle);
+ }
+
+ /**
+ * Returns the given context if the user is the current user or unspecified. Otherwise, returns
+ * an "android" package context as the user.
+ *
+ * @throws IllegalStateException if android package of the other user does not exist
+ */
+ Context asContext(Context context) {
+ if (CURRENT_USER.equals(this)) {
+ return context;
+ }
+ try {
+ return context.createPackageContextAsUser("android", /* flags= */ 0, mUserHandle);
+ } catch (PackageManager.NameNotFoundException e) {
+ throw new IllegalStateException("android package not found.");
+ }
+ }
+
+ /**
+ * Return a content resolver instance of this user.
+ */
+ public ContentResolver getContentResolver(Context context) {
+ return asContext(context).getContentResolver();
+ }
+
+ /**
+ * @return {@link UserHandle} of parent user profile. Otherwise returns {@code null}.
+ */
+ public static UserHandle getParentProfile(UserManager userManager, UserHandle userHandle) {
+ return userManager.getProfileParent(userHandle);
+ }
+
+ /**
+ * Returns true if the this user is a managed profile.
+ */
+ public boolean isManagedProfile(UserManager userManager) {
+ return userManager.isManagedProfile(mUserHandle.getIdentifier());
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ try {
+ if (obj != null) {
+ UserId other = (UserId)obj;
+ return mUserHandle == other.mUserHandle;
+ }
+ } catch (ClassCastException e) {
+ Log.e(TAG, "Cannot check equality due to ", e);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(this.mUserHandle.getIdentifier());
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/BaseItemHolder.java b/src/com/android/providers/media/photopicker/ui/BaseItemHolder.java
new file mode 100644
index 0000000..8d11744
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/BaseItemHolder.java
@@ -0,0 +1,45 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * ViewHolder of a photo item within a RecyclerView.
+ */
+public abstract class BaseItemHolder extends RecyclerView.ViewHolder {
+
+ public BaseItemHolder(Context context, ViewGroup parent, int layout) {
+ this(context, inflateLayout(context, parent, layout));
+ }
+
+ public BaseItemHolder(Context context, View item) {
+ super(item);
+ }
+
+ private static <V extends View> V inflateLayout(Context context, ViewGroup parent, int layout) {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ return (V) inflater.inflate(layout, parent, false);
+ }
+
+ public abstract void bind();
+}
diff --git a/src/com/android/providers/media/photopicker/ui/ImageLoader.java b/src/com/android/providers/media/photopicker/ui/ImageLoader.java
new file mode 100644
index 0000000..dc4ac3e
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/ImageLoader.java
@@ -0,0 +1,76 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+
+import android.graphics.Bitmap;
+import android.graphics.ImageDecoder;
+import android.graphics.drawable.BitmapDrawable;
+import android.util.Log;
+import android.util.Size;
+import android.widget.ImageView;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.data.model.Item;
+
+import java.io.IOException;
+
+
+/**
+ * A class to assist with loading and managing the Images (i.e. thumbnails and preview) associated
+ * with item.
+ */
+public class ImageLoader {
+
+ private static final String TAG = "ImageLoader";
+ private final Context mContext;
+
+ public ImageLoader(Context context) {
+ mContext = context;
+ }
+
+ public void loadThumbanial(Item item, ImageView imageView) {
+ int thumbSize = getThumbSize();
+ final Size size = new Size(thumbSize, thumbSize);
+ try {
+ Bitmap bitmap = mContext.getContentResolver().loadThumbnail(item.getContentUri(),
+ size, null);
+ imageView.setImageDrawable(new BitmapDrawable(mContext.getResources(), bitmap));
+ } catch (IOException ex) {
+ Log.d(TAG, "Loading icon failed", ex);
+ imageView.setImageDrawable(null);
+ }
+ }
+
+ public void loadImagePreview(Item item, ImageView imageView) {
+ // TODO(b/185801129): Use Glide for image loading
+ // TODO(b/185801129): Load image in background thread. Loading the image blocks loading the
+ // layout now.
+ try {
+ imageView.setImageBitmap(ImageDecoder.decodeBitmap(ImageDecoder.createSource(
+ mContext.getContentResolver(), item.getContentUri())));
+ } catch (IOException e) {
+ Log.d(TAG, "Failed loading image for uri " + item.getContentUri(), e);
+ imageView.setImageBitmap(null);
+ }
+ }
+
+ private int getThumbSize() {
+ return mContext.getResources().getDimensionPixelSize(R.dimen.picker_photo_size);
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/PhotoGridHolder.java b/src/com/android/providers/media/photopicker/ui/PhotoGridHolder.java
new file mode 100644
index 0000000..bc1497e
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PhotoGridHolder.java
@@ -0,0 +1,80 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+
+import android.text.format.DateUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.data.model.Item;
+
+/**
+ * ViewHolder of a photo item within a RecyclerView.
+ */
+public class PhotoGridHolder extends BaseItemHolder {
+
+ private final Context mContext;
+ private final ImageLoader mImageLoader;
+ private final ImageView mIconThumb;
+ private final ImageView mIconGif;
+ private final ImageView mIconVideo;
+ private final View mVideoBadgeContainer;
+ private final TextView mVideoDuration;
+
+ public PhotoGridHolder(Context context, ViewGroup parent, ImageLoader imageLoader,
+ boolean canSelectMultiple) {
+ super(context, parent, R.layout.item_photo_grid);
+
+ mIconThumb = itemView.findViewById(R.id.icon_thumbnail);
+ mIconGif = itemView.findViewById(R.id.icon_gif);
+ mVideoBadgeContainer = itemView.findViewById(R.id.video_container);
+ mIconVideo = mVideoBadgeContainer.findViewById(R.id.icon_video);
+ mVideoDuration = mVideoBadgeContainer.findViewById(R.id.video_duration);
+ mContext = context;
+ mImageLoader = imageLoader;
+ final ImageView iconCheck = itemView.findViewById(R.id.icon_check);
+ if (canSelectMultiple) {
+ iconCheck.setVisibility(View.VISIBLE);
+ } else {
+ iconCheck.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void bind() {
+ final Item item = (Item) itemView.getTag();
+ mImageLoader.loadThumbanial(item, mIconThumb);
+
+ if (item.isGif()) {
+ mIconGif.setVisibility(View.VISIBLE);
+ } else {
+ mIconGif.setVisibility(View.GONE);
+ }
+
+ if (item.isVideo()) {
+ mVideoBadgeContainer.setVisibility(View.VISIBLE);
+ mVideoDuration.setText(DateUtils.formatElapsedTime(item.getDuration() / 1000));
+ } else {
+ mVideoBadgeContainer.setVisibility(View.GONE);
+ }
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java
new file mode 100644
index 0000000..9538a84
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java
@@ -0,0 +1,89 @@
+/*
+ * 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.ui;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.providers.media.photopicker.data.model.Item;
+import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Adapts from model to something RecyclerView understands.
+ */
+public class PhotosTabAdapter extends RecyclerView.Adapter<BaseItemHolder> {
+
+ private static final int ITEM_TYPE_PHOTO = 1;
+
+ public static final int COLUMN_COUNT = 3;
+
+ private List<Item> mItemList = new ArrayList<>();
+ private ImageLoader mImageLoader;
+ private View.OnClickListener mOnClickListener;
+ private PickerViewModel mPickerViewModel;
+
+ public PhotosTabAdapter(PickerViewModel pickerViewModel, ImageLoader imageLoader,
+ View.OnClickListener listener) {
+ mImageLoader = imageLoader;
+ mPickerViewModel = pickerViewModel;
+ mOnClickListener = listener;
+ }
+
+ @NonNull
+ @Override
+ public BaseItemHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
+ return new PhotoGridHolder(viewGroup.getContext(), viewGroup, mImageLoader,
+ mPickerViewModel.canSelectMultiple());
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull BaseItemHolder photoHolder, int position) {
+ final Item item = getItem(position);
+ photoHolder.itemView.setTag(item);
+ photoHolder.itemView.setOnClickListener(mOnClickListener);
+ final boolean isItemSelected =
+ mPickerViewModel.getSelectedItems().getValue().containsKey(
+ item.getContentUri());
+ photoHolder.itemView.setSelected(isItemSelected);
+ photoHolder.bind();
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItemList.size();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return ITEM_TYPE_PHOTO;
+ }
+
+ public Item getItem(int position) {
+ return mItemList.get(position);
+ }
+
+ public void updateItemList(List<Item> itemList) {
+ mItemList = itemList;
+ notifyDataSetChanged();
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
new file mode 100644
index 0000000..30a8cd5
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
@@ -0,0 +1,122 @@
+/*
+ * 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.ui;
+
+import static com.android.providers.media.photopicker.ui.PhotosTabAdapter.COLUMN_COUNT;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.PhotoPickerActivity;
+import com.android.providers.media.photopicker.data.model.Item;
+import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
+
+/**
+ * Photos tab fragment for showing the photos
+ */
+public class PhotosTabFragment extends Fragment {
+
+ private PickerViewModel mPickerViewModel;
+ private ImageLoader mImageLoader;
+
+ @Override
+ @NonNull
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ return inflater.inflate(R.layout.fragment_photos_tab, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mImageLoader = new ImageLoader(getContext());
+ RecyclerView photosList = view.findViewById(R.id.photo_list);
+ photosList.setHasFixedSize(true);
+ mPickerViewModel = new ViewModelProvider(requireActivity()).get(PickerViewModel.class);
+ final boolean canSelectMultiple = mPickerViewModel.canSelectMultiple();
+ if (canSelectMultiple) {
+ final Button addButton = view.findViewById(R.id.button_add);
+ addButton.setOnClickListener(v -> {
+ ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf();
+ });
+
+ final Button viewSelectedButton = view.findViewById(R.id.button_view_selected);
+ // Transition to PreviewFragment on clicking "View Selected".
+ viewSelectedButton.setOnClickListener(this::launchPreview);
+ final int bottomBarSize = (int) getResources().getDimension(
+ R.dimen.picker_bottom_bar_size);
+
+ mPickerViewModel.getSelectedItems().observe(this, selectedItemList -> {
+ final View bottomBar = view.findViewById(R.id.picker_bottom_bar);
+ final int size = selectedItemList.size();
+ int dimen = 0;
+ if (size == 0) {
+ bottomBar.setVisibility(View.GONE);
+ } else {
+ bottomBar.setVisibility(View.VISIBLE);
+ addButton.setText(getString(R.string.add) + " (" + size + ")" );
+ dimen = bottomBarSize;
+ }
+ photosList.setPadding(0, 0, 0, dimen);
+ });
+ }
+
+ final PhotosTabAdapter adapter = new PhotosTabAdapter(mPickerViewModel, mImageLoader,
+ this::onItemClick);
+ mPickerViewModel.getItems().observe(this, itemList -> {
+ adapter.updateItemList(itemList);
+ });
+ final GridLayoutManager layoutManager = new GridLayoutManager(getContext(), COLUMN_COUNT);
+ photosList.setLayoutManager(layoutManager);
+ photosList.setAdapter(adapter);
+ }
+
+ private void onItemClick(@NonNull View view) {
+ final boolean isSelectedBefore = view.isSelected();
+
+ if (isSelectedBefore) {
+ mPickerViewModel.deleteSelectedItem((Item) view.getTag());
+ } else {
+ mPickerViewModel.addSelectedItem((Item) view.getTag());
+ }
+
+ if (mPickerViewModel.canSelectMultiple()) {
+ view.setSelected(!isSelectedBefore);
+ } else {
+ // Transition to PreviewFragment.
+ launchPreview(view);
+ }
+ }
+
+ private void launchPreview(View view) {
+ getActivity().getSupportFragmentManager().beginTransaction()
+ .setReorderingAllowed(true)
+ .replace(R.id.fragment_container, PreviewFragment.class, null)
+ .commitNow();
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java b/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java
new file mode 100644
index 0000000..2138935
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java
@@ -0,0 +1,74 @@
+/*
+ * 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.ui;
+
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.providers.media.photopicker.data.model.Item;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Adapter for Preview RecyclerView to preview all images and videos.
+ */
+public class PreviewAdapter extends RecyclerView.Adapter<BaseItemHolder> {
+
+ private static final int ITEM_TYPE_PHOTO = 1;
+
+ private List<Item> mItemList = new ArrayList<>();
+ private ImageLoader mImageLoader;
+
+ public PreviewAdapter(ImageLoader imageLoader) {
+ mImageLoader = imageLoader;
+ }
+
+ @NonNull
+ @Override
+ public BaseItemHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
+ return new PreviewImageHolder(viewGroup.getContext(), viewGroup, mImageLoader);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull BaseItemHolder photoHolder, int position) {
+ final Item item = getItem(position);
+ photoHolder.itemView.setTag(item);
+ photoHolder.bind();
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItemList.size();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return ITEM_TYPE_PHOTO;
+ }
+
+ public Item getItem(int position) {
+ return mItemList.get(position);
+ }
+
+ public void updateItemList(List<Item> itemList) {
+ mItemList = itemList;
+ notifyDataSetChanged();
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/PreviewFragment.java b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java
new file mode 100644
index 0000000..c427207
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java
@@ -0,0 +1,155 @@
+/*
+ * 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.ui;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.FrameLayout.LayoutParams;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.PhotoPickerActivity;
+import com.android.providers.media.photopicker.data.model.Item;
+import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Displays a selected items in one up view. Supports deselecting items.
+ */
+public class PreviewFragment extends Fragment {
+ private PickerViewModel mPickerViewModel;
+ private ViewPager2 mViewPager;
+ private PreviewAdapter mAdapter;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup parent,
+ Bundle savedInstanceState) {
+ mPickerViewModel = new ViewModelProvider(requireActivity()).get(PickerViewModel.class);
+ // TODO(b/185801129): Add handler for back button to go back to previous fragment/activity
+ // instead of exiting the activity.
+ return inflater.inflate(R.layout.fragment_preview, parent, /* attachToRoot */ false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ // Warning: The below code assumes that getSelectedItems will never return null.
+ // We are creating a new ArrayList with selected items, this list used as data for the
+ // adapter. If activity gets killed and recreated, we will lose items that were deselected.
+ // TODO(b/185801129): Save the deselection state instead of making a copy of selected items.
+ final List<Item> selectedItemList = new ArrayList<>(
+ mPickerViewModel.getSelectedItems().getValue().values());
+
+ if (selectedItemList.size() > 1 && !mPickerViewModel.canSelectMultiple() ||
+ selectedItemList.size() <= 0) {
+ // TODO(b/185801129): This should never happen. Add appropriate log messages and
+ // handle UI transitions correctly on this error condition.
+ // We should also handle this situation in ViewModel
+ return;
+ }
+
+ Button addButton = view.findViewById(R.id.preview_add_button);
+
+ // On clicking add button we return the picker result to calling app.
+ // This destroys PickerActivity and all fragments.
+ addButton.setOnClickListener(v -> {
+ ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf();
+ });
+
+ // TODO(b/169737802): Support Videos
+ // Initialize adapter to hold selected items
+ ImageLoader imageLoader = new ImageLoader(getContext());
+ mAdapter = new PreviewAdapter(imageLoader);
+ mAdapter.updateItemList(selectedItemList);
+
+ // Initialize ViewPager2 to swipe between multiple pictures/videos in preview
+ mViewPager = view.findViewById(R.id.preview_viewPager);
+ mViewPager.setAdapter(mAdapter);
+ // TODO(b/185801129) We should set the last saved position instead of zero
+ mViewPager.setCurrentItem(0);
+
+ Button selectButton = view.findViewById(R.id.preview_select_button);
+
+ // Update the select icon and text according to the state of selection while swiping
+ // between photos
+ mViewPager.registerOnPageChangeCallback(new OnPageChangeCallBack(selectButton));
+
+ // Adjust the layout based on Single/Multi select and add appropriate onClick listeners
+ if (!mPickerViewModel.canSelectMultiple()) {
+ // Adjust the select and add button layout for single select
+ LayoutParams layoutParams
+ = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+ addButton.setLayoutParams(layoutParams);
+ selectButton.setVisibility(View.GONE);
+ } else {
+ // Update add button text to include number of items selected.
+ mPickerViewModel.getSelectedItems().observe(this, selectedItems -> {
+ final int size = selectedItems.size();
+ addButton.setText(getString(R.string.add) + " (" + size + ")");
+ });
+ selectButton.setOnClickListener(v -> {
+ onClickSelect(selectButton);
+ });
+ }
+ }
+
+ private void onClickSelect(@NonNull Button selectButton) {
+ // isSelected tracks new state for select button, which is opposite of old state
+ final boolean isSelected = !selectButton.isSelected();
+ final Item currentItem = mAdapter.getItem(mViewPager.getCurrentItem());
+
+ if (isSelected) {
+ mPickerViewModel.addSelectedItem(currentItem);
+ } else {
+ mPickerViewModel.deleteSelectedItem(currentItem);
+ }
+ setSelected(selectButton, isSelected);
+ }
+
+ private class OnPageChangeCallBack extends ViewPager2.OnPageChangeCallback {
+ private final Button mSelectButton;
+
+ public OnPageChangeCallBack(@NonNull Button selectButton) {
+ mSelectButton = selectButton;
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ // No action to take as we don't have deselect view here.
+ if (!mPickerViewModel.canSelectMultiple()) return;
+
+ // Set the appropriate select/deselect state for each item in each page based on the
+ // selection list.
+ setSelected(mSelectButton, mPickerViewModel.getSelectedItems().getValue().containsKey(
+ mAdapter.getItem(position).getContentUri()));
+ }
+ }
+
+ private static void setSelected(@NonNull Button selectButton, boolean isSelected) {
+ selectButton.setSelected(isSelected);
+ selectButton.setText(isSelected ? R.string.deselect : R.string.select);
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/PreviewImageHolder.java b/src/com/android/providers/media/photopicker/ui/PreviewImageHolder.java
new file mode 100644
index 0000000..679652c
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/PreviewImageHolder.java
@@ -0,0 +1,46 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import com.android.providers.media.R;
+
+import com.android.providers.media.photopicker.data.model.Item;
+
+/**
+ * ViewHolder of a photo item within a RecyclerView.
+ */
+public class PreviewImageHolder extends BaseItemHolder {
+ private final ImageLoader mImageLoader;
+ private final ImageView mImageView;
+
+ public PreviewImageHolder(Context context, ViewGroup parent, ImageLoader imageLoader) {
+ super(context, parent, R.layout.item_image_preview);
+
+ mImageView = itemView.findViewById(R.id.preview_imageView);
+ mImageLoader = imageLoader;
+ }
+
+ @Override
+ public void bind() {
+ final Item item = (Item) itemView.getTag();
+ mImageLoader.loadImagePreview(item, mImageView);
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/ui/SquareImageView.java b/src/com/android/providers/media/photopicker/ui/SquareImageView.java
new file mode 100644
index 0000000..b3a0af3
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/ui/SquareImageView.java
@@ -0,0 +1,43 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+/**
+ * Ensures that imageView is always square.
+ */
+public class SquareImageView extends ImageView {
+ public SquareImageView(Context context) {
+ super(context);
+ }
+
+ public SquareImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SquareImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec);
+ }
+}
diff --git a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
new file mode 100644
index 0000000..86f3c47
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
@@ -0,0 +1,145 @@
+/*
+ * 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.viewmodel;
+
+import android.annotation.NonNull;
+import android.app.Application;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.android.providers.media.photopicker.data.ItemsProvider;
+import com.android.providers.media.photopicker.data.UserIdManager;
+import com.android.providers.media.photopicker.data.model.Item;
+import com.android.providers.media.photopicker.data.model.UserId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * PickerViewModel to store and handle data for PhotoPickerActivity.
+ */
+public class PickerViewModel extends AndroidViewModel {
+ public static final String TAG = "PhotoPicker";
+
+ private MutableLiveData<List<Item>> mItemList;
+ private MutableLiveData<Map<Uri, Item>> mSelectedItemList = new MutableLiveData<>();
+ private final ItemsProvider mItemsProvider;
+ private final UserIdManager mUserIdManager;
+ private boolean mSelectMultiple = false;
+
+ public PickerViewModel(@NonNull Application application) {
+ super(application);
+ final Context context = application.getApplicationContext();
+ mItemsProvider = new ItemsProvider(context);
+ mUserIdManager = UserIdManager.create(context);
+ }
+
+ /**
+ * @return the Map of selected Item.
+ */
+ public LiveData<Map<Uri, Item>> getSelectedItems() {
+ if (mSelectedItemList.getValue() == null) {
+ Map<Uri, Item> itemList = new HashMap<>();
+ mSelectedItemList.setValue(itemList);
+ }
+ return mSelectedItemList;
+ }
+
+ /**
+ * Add the selected ItemInfo.
+ */
+ public void addSelectedItem(Item item) {
+ if (mSelectedItemList.getValue() == null) {
+ Map<Uri, Item> itemList = new HashMap<>();
+ mSelectedItemList.setValue(itemList);
+ }
+ mSelectedItemList.getValue().put(item.getContentUri(), item);
+ mSelectedItemList.postValue(mSelectedItemList.getValue());
+ }
+
+ /**
+ * Delete the selected ItemInfo.
+ */
+ public void deleteSelectedItem(Item item) {
+ if (mSelectedItemList.getValue() == null) {
+ return;
+ }
+ mSelectedItemList.getValue().remove(item.getContentUri());
+ mSelectedItemList.postValue(mSelectedItemList.getValue());
+ }
+
+ /**
+ * @return the list of Items with all photos and videos on the device.
+ */
+ public LiveData<List<Item>> getItems() {
+ if (mItemList == null) {
+ updateItems();
+ }
+ return mItemList;
+ }
+
+ private List<Item> loadItems() {
+ final List<Item> items = new ArrayList<>();
+ final UserId userId = mUserIdManager.getCurrentUserProfileId();
+ // TODO(b/168001592) call getItems() from worker thread.
+ Cursor cursor = mItemsProvider.getItems(null, 0, -1, null, userId);
+ if (cursor == null) {
+ return items;
+ }
+
+ while (cursor.moveToNext()) {
+ // TODO(b/188394433): Return userId in the cursor so that we do not need to pass it
+ // here again.
+ items.add(Item.fromCursor(cursor, userId));
+ }
+
+ Log.d(TAG, "Loaded " + items.size() + " items for user " + userId.toString());
+ return items;
+ }
+
+ /**
+ * Update the item List
+ */
+ public void updateItems() {
+ if (mItemList == null) {
+ mItemList = new MutableLiveData<>();
+ }
+ mItemList.postValue(loadItems());
+ }
+
+ /**
+ * Return whether supports multiple select or not
+ */
+ public boolean canSelectMultiple() {
+ return mSelectMultiple;
+ }
+
+ /**
+ * Set the value for whether supports multiple select or not
+ */
+ public void setSelectMultiple(boolean allowMultiple) {
+ mSelectMultiple = allowMultiple;
+ }
+}
diff --git a/src/com/android/providers/media/util/LongArray.java b/src/com/android/providers/media/util/LongArray.java
index 630b41f..7ea750e 100644
--- a/src/com/android/providers/media/util/LongArray.java
+++ b/src/com/android/providers/media/util/LongArray.java
@@ -31,7 +31,7 @@
private LongArray(long[] array, int size) {
mValues = array;
- mSize = checkArgumentInRange(size, 0, array.length, "size");
+ mSize = Preconditions.checkArgumentInRange(size, 0, array.length, "size");
}
/**
@@ -73,7 +73,7 @@
* created from the current content of this LongArray padded with 0s.
*/
public void resize(int newSize) {
- checkArgumentNonnegative(newSize);
+ Preconditions.checkArgumentNonnegative(newSize);
if (newSize <= mValues.length) {
Arrays.fill(mValues, newSize, mValues.length, 0);
} else {
@@ -222,29 +222,6 @@
return true;
}
- public static int checkArgumentNonnegative(final int value) {
- if (value < 0) {
- throw new IllegalArgumentException();
- }
-
- return value;
- }
-
- public static int checkArgumentInRange(int value, int lower, int upper,
- String valueName) {
- if (value < lower) {
- throw new IllegalArgumentException(
- String.format(
- "%s is out of range of [%d, %d] (too low)", valueName, lower, upper));
- } else if (value > upper) {
- throw new IllegalArgumentException(
- String.format(
- "%s is out of range of [%d, %d] (too high)", valueName, lower, upper));
- }
-
- return value;
- }
-
public static void checkBounds(int len, int index) {
if (index < 0 || len <= index) {
throw new ArrayIndexOutOfBoundsException("length=" + len + "; index=" + index);
diff --git a/src/com/android/providers/media/util/Preconditions.java b/src/com/android/providers/media/util/Preconditions.java
new file mode 100644
index 0000000..fb87130
--- /dev/null
+++ b/src/com/android/providers/media/util/Preconditions.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.util;
+
+public final class Preconditions {
+
+ /**
+ * Ensures that that the argument numeric value is non-negative (greater than or equal to 0).
+ *
+ * @param value a numeric int value
+ * @return the validated numeric value
+ * @throws IllegalArgumentException if {@code value} was negative
+ */
+ public static int checkArgumentNonnegative(final int value) {
+ if (value < 0) {
+ throw new IllegalArgumentException();
+ }
+
+ return value;
+ }
+
+ /**
+ * Ensures that the argument int value is within the inclusive range.
+ *
+ * @param value a int value
+ * @param lower the lower endpoint of the inclusive range
+ * @param upper the upper endpoint of the inclusive range
+ * @param valueName the name of the argument to use if the check fails
+ *
+ * @return the validated int value
+ *
+ * @throws IllegalArgumentException if {@code value} was not within the range
+ */
+ public static int checkArgumentInRange(int value, int lower, int upper,
+ String valueName) {
+ if (value < lower) {
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is out of range of [%d, %d] (too low)", valueName, lower, upper));
+ } else if (value > upper) {
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is out of range of [%d, %d] (too high)", valueName, lower, upper));
+ }
+
+ return value;
+ }
+}
diff --git a/tests/Android.bp b/tests/Android.bp
index 0cf1a3b..32d87a0 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -106,6 +106,7 @@
"mockito-target",
"modules-utils-build",
"truth-prebuilt",
+ "com.google.android.material_material",
"cts-install-lib",
],
diff --git a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
index 7de9834..1f098da 100644
--- a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
+++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
@@ -16,12 +16,18 @@
package com.android.providers.media;
+import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_READ;
+import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_WRITE;
+import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_CREATE;
+import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_DELETE;
+
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Environment;
import android.provider.MediaStore;
+import android.system.OsConstants;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
@@ -97,7 +103,10 @@
// We can write our file
FileOpenResult result = sMediaProvider.onFileOpenForFuse(
- file.getPath(), file.getPath(), sTestUid, 0 /* tid */, 0 /* transforms_reason */,
+ file.getPath(),
+ file.getPath(),
+ sTestUid,
+ 0 /* tid */, 0 /* transforms_reason */,
true /* forWrite */, false /* redact */, false /* transcode_metrics */);
Truth.assertThat(result.status).isEqualTo(0);
Truth.assertThat(result.redactionRanges).isEqualTo(new long[0]);
@@ -138,14 +147,76 @@
}
@Test
- public void test_isOpendirAllowedForFuse() throws Exception {
- Truth.assertThat(sMediaProvider.isOpendirAllowedForFuse(
- sTestDir.getPath(), sTestUid, /* forWrite */ false)).isEqualTo(0);
- }
+ public void test_isDirAccessAllowedForFuse() throws Exception {
+ //verify can create and write but not delete top-level default folder
+ final File topLevelDefaultDir = Environment.buildExternalStoragePublicDirs(
+ Environment.DIRECTORY_PICTURES)[0];
+ final String topLevelDefaultDirPath = topLevelDefaultDir.getPath();
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_READ)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_CREATE)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_WRITE)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_DELETE)).isEqualTo(
+ OsConstants.EACCES);
- @Test
- public void test_isDirectoryCreationOrDeletionAllowedForFuse() throws Exception {
- Truth.assertThat(sMediaProvider.isDirectoryCreationOrDeletionAllowedForFuse(
- sTestDir.getPath(), sTestUid, true)).isEqualTo(0);
+ //verify cannot create or write top-level non-default folder, but can read it
+ final File topLevelNonDefaultDir = Environment.buildExternalStoragePublicDirs(
+ "non-default-dir")[0];
+ final String topLevelNonDefaultDirPath = topLevelNonDefaultDir.getPath();
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_READ)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_CREATE)).isEqualTo(
+ OsConstants.EACCES);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_WRITE)).isEqualTo(OsConstants.EACCES);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ topLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_DELETE)).isEqualTo(OsConstants.EACCES);
+
+ //verify can read, create, write and delete random non-top-level folder
+ final File lowerLevelNonDefaultDir = new File(topLevelDefaultDir,
+ "subdir" + System.nanoTime());
+ lowerLevelNonDefaultDir.mkdirs();
+ final String lowerLevelNonDefaultDirPath = lowerLevelNonDefaultDir.getPath();
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ lowerLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_READ)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ lowerLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_CREATE)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ lowerLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_WRITE)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ lowerLevelNonDefaultDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_DELETE)).isEqualTo(0);
+
+ //verify cannot update outside /storage folder
+ final File rootDir = new File("/myfolder");
+ final String rootDirPath = rootDir.getPath();
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ rootDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_READ)).isEqualTo(0);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ rootDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_CREATE)).isEqualTo(OsConstants.EPERM);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ rootDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_WRITE)).isEqualTo(OsConstants.EPERM);
+ Truth.assertThat(sMediaProvider.isDirAccessAllowedForFuse(
+ rootDirPath, sTestUid,
+ DIRECTORY_ACCESS_FOR_DELETE)).isEqualTo(OsConstants.EPERM);
+
}
}
diff --git a/tests/src/com/android/providers/media/photopicker/LocalItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/LocalItemsProviderTest.java
new file mode 100644
index 0000000..24b2a06
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/LocalItemsProviderTest.java
@@ -0,0 +1,788 @@
+/*
+ * 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 android.provider.MediaStore.VOLUME_EXTERNAL;
+
+import static com.android.providers.media.util.MimeUtils.isImageMimeType;
+import static com.android.providers.media.util.MimeUtils.isVideoMimeType;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+
+import android.Manifest;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.MediaStore;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.android.providers.media.photopicker.data.LocalItemsProvider;
+import com.android.providers.media.photopicker.data.model.Category;
+import com.android.providers.media.photopicker.data.model.UserId;
+import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+
+public class LocalItemsProviderTest {
+
+ /**
+ * To help avoid flaky tests, give ourselves a unique nonce to be used for
+ * all filesystem paths, so that we don't risk conflicting with previous
+ * test runs.
+ */
+ private static final String NONCE = String.valueOf(System.nanoTime());
+ private static final String TAG = "LocalItemsProviderTest";
+ private static final String VIDEO_FILE_NAME = TAG + "_file_" + NONCE + ".mp4";
+ 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 LocalItemsProvider sLocalItemsProvider;
+
+ @Before
+ public void setUp() {
+ InstrumentationRegistry.getInstrumentation().getUiAutomation()
+ .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE,
+ Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
+ Manifest.permission.READ_DEVICE_CONFIG,
+ Manifest.permission.INTERACT_ACROSS_USERS);
+
+ final Context context = InstrumentationRegistry.getTargetContext();
+ sIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false);
+ sIsolatedResolver = sIsolatedContext.getContentResolver();
+ sLocalItemsProvider = new LocalItemsProvider(sIsolatedContext);
+
+ // Wait for MediaStore to be Idle to reduce flakes caused by database updates
+ MediaStore.waitForIdle(sIsolatedResolver);
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_CAMERA}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_camera() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // Create 1 image file in Camera dir to test
+ // {@link LocalItemsProvider#getCategories(UserId)}.
+ final File cameraDir = getCameraDir();
+ File imageFile = assertCreateNewImage(cameraDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_CAMERA, /* numberOfItems */ 1);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_CAMERA}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_not_camera() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // negative test case: image file which should not be returned in Camera category
+ final File picturesDir = getPicturesDir();
+ File nonCameraImageFile = assertCreateNewImage(picturesDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_CAMERA, /* numberOfItems */ 0);
+ } finally {
+ nonCameraImageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_VIDEOS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_videos() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // Create 1 video file in Movies dir to test
+ // {@link LocalItemsProvider#getCategories(UserId)}.
+ final File moviesDir = getMoviesDir();
+ File videoFile = assertCreateNewVideo(moviesDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_VIDEOS, /* numberOfItems */ 1);
+ } finally {
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_VIDEOS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_not_videos() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // negative test case: image file which should not be returned in Videos category
+ final File picturesDir = getPicturesDir();
+ File imageFile = assertCreateNewImage(picturesDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_VIDEOS, /* numberOfItems */ 0);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_SCREENSHOTS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_screenshots() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // Create 1 image file in Screenshots dir to test
+ // {@link LocalItemsProvider#getCategories(UserId)}
+ final File screenshotsDir = getScreenshotsDir();
+ File imageFile = assertCreateNewImage(screenshotsDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_SCREENSHOTS, /* numberOfItems */ 1);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_SCREENSHOTS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_not_screenshots() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // negative test case: image file which should not be returned in Screenshots category
+ final File cameraDir = getCameraDir();
+ File imageFile = assertCreateNewImage(cameraDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_SCREENSHOTS, /* numberOfItems */ 0);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_FAVORITES}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_favorites() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // positive test case: image file which should be returned in favorites category
+ final File picturesDir = getPicturesDir();
+ final File imageFile = assertCreateNewImage(picturesDir);
+ setIsFavorite(imageFile);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_FAVORITES, /* numberOfItems */1);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_FAVORITES}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_not_favorites() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // negative test case: image file which should not be returned in favorites category
+ final File picturesDir = getPicturesDir();
+ final File nonFavImageFile = assertCreateNewImage(picturesDir);
+ try {
+ assertGetCategoriesMatchSingle(Category.CATEGORY_FAVORITES, /* numberOfItems */ 0);
+ } finally {
+ nonFavImageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_CAMERA} and {@link Category#CATEGORY_VIDEOS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_camera_and_videos() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // Create 1 video file in Camera dir to test
+ // {@link LocalItemsProvider#getCategories(UserId)}.
+ final File cameraDir = getCameraDir();
+ File videoFile = assertCreateNewVideo(cameraDir);
+ try {
+ assertGetCategoriesMatchMultiple(Category.CATEGORY_CAMERA, Category.CATEGORY_VIDEOS,
+ /* numberOfItemsInCamera */ 1,
+ /* numberOfItemsInVideos */ 1);
+ } finally {
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getCategories(UserId)} to return correct info about
+ * {@link Category#CATEGORY_CAMERA} and {@link Category#CATEGORY_VIDEOS}.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetCategories_screenshots_and_favorites() throws Exception {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c.getCount()).isEqualTo(0);
+
+ // Create 1 image file in Screenshots dir to test
+ // {@link LocalItemsProvider#getCategories(UserId)}
+ final File screenshotsDir = getScreenshotsDir();
+ File imageFile = assertCreateNewImage(screenshotsDir);
+ setIsFavorite(imageFile);
+ try {
+ assertGetCategoriesMatchMultiple(Category.CATEGORY_SCREENSHOTS,
+ Category.CATEGORY_FAVORITES,
+ /* numberOfItemsInScreenshots */ 1,
+ /* numberOfItemsInFavorites */ 1);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} to return all
+ * images and videos.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItems() throws Exception {
+ Cursor res = sLocalItemsProvider.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 LocalItemsProvider#getItems(String, int, int, String, UserId)}.
+ // Both files should be returned.
+ File imageFile = assertCreateNewImage();
+ File videoFile = assertCreateNewVideo();
+ try {
+ res = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ null, /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 2);
+
+ assertThatOnlyImagesVideos(res);
+ assertThatAllImagesVideos(res.getCount());
+ } finally {
+ imageFile.delete();
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link {@link LocalItemsProvider#getItems(String, int, int, String, UserId)}} does not
+ * return hidden images/videos.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItems_nonMedia() throws Exception {
+ Cursor res = sLocalItemsProvider.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 LocalItemsProvider#getItems(String, int, int, String, UserId)}.
+ // Both should not be returned.
+ File hiddenDir = createHiddenDir();
+ File imageFileHidden = assertCreateNewImage(hiddenDir);
+ File videoFileHidden = assertCreateNewVideo(hiddenDir);
+ try {
+ res = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ null, /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ } finally {
+ imageFileHidden.delete();
+ videoFileHidden.delete();
+ hiddenDir.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} to return all
+ * images and videos based on the mimeType. Image mimeType should only return images.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsImages() throws Exception {
+ Cursor res = sLocalItemsProvider.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 LocalItemsProvider#getItems(String, int, int, String, UserId)}.
+ // Only 1 should be returned.
+ File imageFile = assertCreateNewImage();
+ File videoFile = assertCreateNewVideo();
+ try {
+ res = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "image/*", /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 1);
+
+ assertThatOnlyImages(res);
+ assertThatAllImages(res.getCount());
+ } finally {
+ imageFile.delete();
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} to return all
+ * images and videos based on the mimeType. Image mimeType should only return images.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsImages_png() throws Exception {
+ Cursor res = sLocalItemsProvider.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 = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "image/png", /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ } finally {
+ imageFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} does not return
+ * hidden images/videos.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsImages_nonMedia() throws Exception {
+ Cursor res = sLocalItemsProvider.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 LocalItemsProvider#getItems(String, int, int, String)}.
+ // Both should not be returned.
+ File hiddenDir = createHiddenDir();
+ File imageFileHidden = assertCreateNewImage(hiddenDir);
+ File videoFileHidden = assertCreateNewVideo(hiddenDir);
+ try {
+ res = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "image/*", /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ } finally {
+ imageFileHidden.delete();
+ videoFileHidden.delete();
+ hiddenDir.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} to return all
+ * images and videos based on the mimeType. Video mimeType should only return videos.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsVideos() throws Exception {
+ Cursor res = sLocalItemsProvider.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 LocalItemsProvider#getItems(String, int, int, String)}.
+ // Only 1 should be returned.
+ File imageFile = assertCreateNewImage();
+ File videoFile = assertCreateNewVideo();
+ try {
+ res = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "video/*", /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 1);
+
+ assertThatOnlyVideos(res);
+ assertThatAllVideos(res.getCount());
+ } finally {
+ imageFile.delete();
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} to return all
+ * images and videos based on the mimeType. Image mimeType should only return images.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsVideos_mp4() throws Exception {
+ Cursor res = sLocalItemsProvider.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 = sLocalItemsProvider.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);
+ } finally {
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} does not return
+ * hidden images/videos.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsVideos_nonMedia() throws Exception {
+ Cursor res = sLocalItemsProvider.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 = sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "video/*", /* userId */ null);
+ assertThat(res).isNotNull();
+ final int laterCountOfItems = res.getCount();
+
+ assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ } finally {
+ imageFileHidden.delete();
+ videoFileHidden.delete();
+ hiddenDir.delete();
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} throws error for
+ * invalid param for mimeType.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsInvalidParam() throws Exception {
+ try {
+ sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "audio/*", /* userId */ null);
+ fail("Expected IllegalArgumentException for audio mimeType");
+ } catch (IllegalArgumentException expected) {
+ // Expected flow
+ }
+ }
+
+ /**
+ * Tests {@link LocalItemsProvider#getItems(String, int, int, String, UserId)} throws error for
+ * invalid param for mimeType.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testGetItemsAllMimeType() throws Exception {
+ try {
+ sLocalItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
+ /* mimeType */ "*/*", /* userId */ null);
+ fail("Expected IllegalArgumentException for audio mimeType");
+ } catch (IllegalArgumentException expected) {
+ // Expected flow
+ }
+ }
+
+ private void assertGetCategoriesMatchSingle(String expectedCategoryName,
+ int expectedNumberOfItems) {
+ if (expectedNumberOfItems == 0) {
+ assertCategoriesNoMatch(expectedCategoryName);
+ return;
+ }
+
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c).isNotNull();
+ assertThat(c.getCount()).isEqualTo(1);
+
+ // Assert that only expected category is returned and has expectedNumberOfItems items in it
+ assertThat(c.moveToFirst()).isTrue();
+ final int nameColumnIndex = c.getColumnIndexOrThrow(Category.CategoryColumns.NAME);
+ final int numOfItemsColumnIndex = c.getColumnIndexOrThrow(
+ Category.CategoryColumns.NUMBER_OF_ITEMS);
+
+ final String categoryName = c.getString(nameColumnIndex);
+ final int numOfItems = c.getInt(numOfItemsColumnIndex);
+
+ assertThat(categoryName).isEqualTo(expectedCategoryName);
+ assertThat(numOfItems).isEqualTo(expectedNumberOfItems);
+ }
+
+ private void assertCategoriesNoMatch(String expectedCategoryName) {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ while (c != null && c.moveToNext()) {
+ final int nameColumnIndex = c.getColumnIndexOrThrow(Category.CategoryColumns.NAME);
+ final String categoryName = c.getString(nameColumnIndex);
+ assertThat(categoryName).isNotEqualTo(expectedCategoryName);
+ }
+ }
+
+ private void assertGetCategoriesMatchMultiple(String category1, String category2,
+ int numberOfItems1, int numberOfItems2) {
+ Cursor c = sLocalItemsProvider.getCategories(/* userId */ null);
+ assertThat(c).isNotNull();
+ assertThat(c.getCount()).isEqualTo(2);
+
+ // Assert that category1 and category2 is returned and has numberOfItems1 and
+ // numberOfItems2 items in them respectively.
+ boolean isCategory1Returned = false;
+ boolean isCategory2Returned = false;
+ while (c.moveToNext()) {
+ final int nameColumnIndex = c.getColumnIndexOrThrow(Category.CategoryColumns.NAME);
+ final int numOfItemsColumnIndex = c.getColumnIndexOrThrow(
+ Category.CategoryColumns.NUMBER_OF_ITEMS);
+
+ final String categoryName = c.getString(nameColumnIndex);
+ final int numOfItems = c.getInt(numOfItemsColumnIndex);
+
+
+ if (categoryName.equals(category1)) {
+ isCategory1Returned = true;
+ assertThat(numOfItems).isEqualTo(numberOfItems1);
+ } else if (categoryName.equals(category2)) {
+ isCategory2Returned = true;
+ assertThat(numOfItems).isEqualTo(numberOfItems2);
+ }
+ }
+
+ assertThat(isCategory1Returned).isTrue();
+ assertThat(isCategory2Returned).isTrue();
+ }
+
+ private void setIsFavorite(File file) {
+ final Uri uri = MediaStore.scanFile(sIsolatedResolver, 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);
+ // Wait for MediaStore to be Idle to reduce flakes caused by database updates
+ MediaStore.waitForIdle(sIsolatedResolver);
+ }
+
+ private void assertThatOnlyImagesVideos(Cursor c) throws Exception {
+ while (c.moveToNext()) {
+ int mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
+ String mimeType = c.getString(mimeTypeColumn);
+ assertThat(isImageMimeType(mimeType) || isVideoMimeType(mimeType)).isTrue();
+ }
+ }
+
+ private void assertThatOnlyImages(Cursor c) throws Exception {
+ while (c.moveToNext()) {
+ int mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
+ String mimeType = c.getString(mimeTypeColumn);
+ assertThat(isImageMimeType(mimeType)).isTrue();
+ }
+ }
+
+ private void assertThatOnlyVideos(Cursor c) throws Exception {
+ while (c.moveToNext()) {
+ int mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
+ String mimeType = c.getString(mimeTypeColumn);
+ assertThat(isVideoMimeType(mimeType)).isTrue();
+ }
+ }
+
+ private void assertThatAllImagesVideos(int count) {
+ int countOfImages = getCountOfMediaStoreImages();
+ int countOfVideos = getCountOfMediaStoreVideos();
+ assertThat(count).isEqualTo(countOfImages + countOfVideos);
+ }
+
+ private void assertThatAllImages(int count) {
+ int countOfImages = getCountOfMediaStoreImages();
+ assertThat(count).isEqualTo(countOfImages);
+ }
+
+ private void assertThatAllVideos(int count) {
+ int countOfVideos = getCountOfMediaStoreVideos();
+ assertThat(count).isEqualTo(countOfVideos);
+ }
+
+ private int getCountOfMediaStoreImages() {
+ try (Cursor c = sIsolatedResolver.query(
+ MediaStore.Images.Media.getContentUri(VOLUME_EXTERNAL), null, null, null)) {
+ assertThat(c.moveToFirst()).isTrue();
+ return c.getCount();
+ }
+ }
+
+ private int getCountOfMediaStoreVideos() {
+ try (Cursor c = sIsolatedResolver.query(
+ MediaStore.Video.Media.getContentUri(VOLUME_EXTERNAL), null, null, null)) {
+ assertThat(c.moveToFirst()).isTrue();
+ return c.getCount();
+ }
+ }
+
+ private File assertCreateNewVideo(File dir) throws Exception {
+ return assertCreateNewFile(dir, VIDEO_FILE_NAME);
+ }
+
+ private File assertCreateNewImage(File dir) throws Exception {
+ return assertCreateNewFile(dir, IMAGE_FILE_NAME);
+ }
+
+ private File assertCreateNewVideo() throws Exception {
+ return assertCreateNewFile(getDownloadsDir(), VIDEO_FILE_NAME);
+ }
+
+ private File assertCreateNewImage() throws Exception {
+ return assertCreateNewFile(getDownloadsDir(), IMAGE_FILE_NAME);
+ }
+
+ private File assertCreateNewFile(File dir, String fileName) throws Exception {
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ assertThat(dir.exists()).isTrue();
+ final File file = new File(dir, fileName);
+ assertThat(file.createNewFile()).isTrue();
+
+ MediaStore.scanFile(sIsolatedResolver, file);
+ return file;
+ }
+
+ private File getDownloadsDir() {
+ return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS);
+ }
+
+ private File getDcimDir() {
+ return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DCIM);
+ }
+
+ private File getPicturesDir() {
+ return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_PICTURES);
+ }
+
+ private File getMoviesDir() {
+ return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_MOVIES);
+ }
+
+ private File getCameraDir() {
+ return new File(getDcimDir(), "Camera");
+ }
+
+ private File getScreenshotsDir() {
+ return new File(getPicturesDir(), Environment.DIRECTORY_SCREENSHOTS);
+ }
+
+ private File createHiddenDir() throws Exception {
+ File parentDir = new File(Environment.getExternalStorageDirectory(),
+ Environment.DIRECTORY_DOWNLOADS);
+ File dir = new File(parentDir, HIDDEN_DIR_NAME);
+ dir.mkdirs();
+ File nomedia = new File(dir, ".nomedia");
+ nomedia.createNewFile();
+
+ MediaStore.scanFile(sIsolatedResolver, nomedia);
+
+ return dir;
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java b/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java
new file mode 100644
index 0000000..33b2214
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.data;
+
+import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
+import static com.android.providers.media.MediaProvider.REDACTED_URI_ID_SIZE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.content.ClipData;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore;
+import android.util.Log;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.android.providers.media.photopicker.data.model.Item;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class PickerResultTest {
+ private static final String TAG = "PickerResultTest";
+ private static final String IMAGE_FILE_NAME = TAG + "_file_" + "%d" + ".jpg";
+
+ private Context mContext;
+
+ @Before
+ public void setUp() throws Exception {
+ mContext = InstrumentationRegistry.getTargetContext();
+ }
+
+ /**
+ * Tests {@link PickerResult#getPickerResponseIntent(Context, List)} with single item
+ * @throws Exception
+ */
+ @Test
+ public void testGetResultSingle() throws Exception {
+ List<Item> items = null;
+ try {
+ items = createItemSelection(1);
+ final Intent intent = PickerResult.getPickerResponseIntent(mContext, items);
+
+ final Uri result = intent.getData();
+ assertUriPermission(result);
+ } finally {
+ deleteFiles(items);
+ }
+ }
+
+ /**
+ * Tests {@link PickerResult#getPickerResponseIntent(Context, List)} with multiple items
+ * @throws Exception
+ */
+ @Test
+ public void testGetResultMultiple() throws Exception {
+ ArrayList<Item> items = null;
+ try {
+ final int itemCount = 3;
+ items = createItemSelection(itemCount);
+ final Intent intent = PickerResult.getPickerResponseIntent(mContext, items);
+
+ final ClipData clipData = intent.getClipData();
+ final int count = clipData.getItemCount();
+ assertThat(count).isEqualTo(itemCount);
+ for (int i = 0; i < count; i++) {
+ assertUriPermission(clipData.getItemAt(i).getUri());
+ }
+ } finally {
+ deleteFiles(items);
+ }
+ }
+
+ private void assertUriPermission(Uri uri) throws Exception {
+ assertRedactedUri(uri);
+ // TODO (b/189086247): Test with non-RES app
+ assertReadAccess(uri);
+ assertNoWriteAccess(uri);
+ }
+
+ private void assertRedactedUri(Uri uri) {
+ final String uriId = uri.getLastPathSegment();
+ assertThat(uriId.startsWith("RUID")).isTrue();
+ assertThat(uriId.length()).isEqualTo(REDACTED_URI_ID_SIZE);
+ }
+
+ private void assertReadAccess(Uri uri) throws Exception {
+ try (ParcelFileDescriptor pfd = mContext.getContentResolver().openFileDescriptor(uri,
+ "r")) {
+ }
+ }
+
+ private void assertNoWriteAccess(Uri uri) throws Exception {
+ try (ParcelFileDescriptor pfd = mContext.getContentResolver().openFileDescriptor(uri,
+ "w")) {
+ fail("Expected write access to be blocked");
+ } catch (Exception expected) {
+ }
+ }
+
+ /**
+ * Returns a PhotoSelection on which the test app does not have access to.
+ */
+ private ArrayList<Item> createItemSelection(int count) throws Exception {
+ ArrayList<Item> selectedItemList = new ArrayList<>();
+
+ for (int i = 0; i < count; i++) {
+ selectedItemList.add(createImageItem());
+ }
+ return selectedItemList;
+ }
+
+ /**
+ * Returns a PhotoSelection item on which the test app does not have access to.
+ */
+ private Item createImageItem() throws Exception {
+ // Create an image and revoke test app's access on it
+ final Uri imageUri = assertCreateNewImage();
+ clearMediaOwner(imageUri, mContext.getUserId());
+
+ // Create an item for the selection, since PickerResult only uses Item#getContentUri(),
+ // no need to create actual item, and can mock the class.
+ final Item imageItem = mock(Item.class);
+ when(imageItem.getContentUri()).thenReturn(imageUri);
+
+ return imageItem;
+ }
+
+ private Uri assertCreateNewImage() throws Exception {
+ return assertCreateNewFile(getDownloadsDir(), getImageFileName());
+ }
+
+ private Uri assertCreateNewFile(File dir, String fileName) throws Exception {
+ final File file = new File(dir, fileName);
+ assertThat(file.createNewFile()).isTrue();
+ return MediaStore.scanFile(mContext.getContentResolver(), file);
+ }
+
+ private String getImageFileName() {
+ // To help avoid flaky tests, give ourselves a unique nonce to be used for
+ // all filesystem paths, so that we don't risk conflicting with previous
+ // test runs.
+ return String.format(IMAGE_FILE_NAME, System.nanoTime());
+ }
+
+ private File getDownloadsDir() {
+ return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS);
+ }
+
+ private static void clearMediaOwner(Uri uri, int userId) throws IOException {
+ final String cmd = String.format(
+ "content update --uri %s --user %d --bind owner_package_name:n:",
+ uri, userId);
+ runShellCommand(InstrumentationRegistry.getInstrumentation(), cmd);
+ }
+
+ private void deleteFiles(List<Item> items) {
+ if (items == null) return;
+
+ for (Item item : items) {
+ deleteFile(item);
+ }
+ }
+
+ private void deleteFile(Item item) {
+ if (item == null) return;
+
+ final String cmd = String.format("content delete --uri %s --user %d ",
+ item.getContentUri(), mContext.getUserId());
+ try {
+ runShellCommand(InstrumentationRegistry.getInstrumentation(), cmd);
+ } catch (Exception e) {
+ // Ignore the exception but log it to help debug test failures
+ Log.d(TAG, "Couldn't delete file " + item.getContentUri(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java b/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java
new file mode 100644
index 0000000..93d3cc8
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+import com.android.providers.media.photopicker.data.model.UserId;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class UserIdManagerTest {
+ private final UserHandle personalUser = UserHandle.SYSTEM;
+ private final UserHandle managedUser1 = UserHandle.of(100);
+ // otherUser1 and otherUser2 are users without any work (aka managed) profile.
+ private final UserHandle otherUser1 = UserHandle.of(200);
+ private final UserHandle otherUser2 = UserHandle.of(201);
+
+ private final Context mockContext = mock(Context.class);
+ private final UserManager mockUserManager = mock(UserManager.class);
+
+ private UserIdManager userIdManager;
+
+ @Before
+ public void setUp() throws Exception {
+ when(mockContext.getApplicationContext()).thenReturn(mockContext);
+
+ when(mockUserManager.isManagedProfile(managedUser1.getIdentifier())).thenReturn(true);
+ when(mockUserManager.isManagedProfile(personalUser.getIdentifier())).thenReturn(false);
+ when(mockUserManager.getProfileParent(managedUser1)).thenReturn(personalUser);
+ when(mockUserManager.isManagedProfile(otherUser1.getIdentifier())).thenReturn(false);
+ when(mockUserManager.isManagedProfile(otherUser2.getIdentifier())).thenReturn(false);
+
+ when(mockContext.getSystemServiceName(UserManager.class)).thenReturn("mockUserManager");
+ when(mockContext.getSystemService(UserManager.class)).thenReturn(mockUserManager);
+ }
+
+ // common cases for User Profiles
+ @Test
+ public void testUserIds_personaUser_currentUserIsPersonalUser() {
+ // Returns the current user if there is only 1 user.
+ UserId currentUser = UserId.of(personalUser);
+ initializeUserIdManager(currentUser, Arrays.asList(personalUser));
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
+
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ }
+
+ @Test
+ public void testUserIds_personalUserAndManagedUser_currentUserIsPersonalUser() {
+ // Returns both if there are personal and managed users.
+ UserId currentUser = UserId.of(personalUser);
+ initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+
+ assertThat(userIdManager.isPersonalUserId()).isTrue();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
+
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(currentUser);
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(UserId.of(managedUser1));
+ }
+
+ @Test
+ public void testUserIds_personalUserAndManagedUser_currentUserIsManagedUser() {
+ // Returns both if there are system and managed users.
+ UserId currentUser = UserId.of(managedUser1);
+ initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isTrue();
+
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ }
+
+ // other cases for User Profiles involving different users
+ @Test
+ public void testUserIds_otherUsers_currentUserIsOtherUser2() {
+ // When there is no managed user, returns the current user.
+ UserId currentUser = UserId.of(otherUser2);
+ initializeUserIdManager(currentUser, Arrays.asList(otherUser1, otherUser2));
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
+
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ }
+
+ @Test
+ public void testUserIds_otherUserAndManagedUserAndPersonalUser_currentUserIsOtherUser(
+ ) {
+ UserId currentUser = UserId.of(otherUser1);
+ initializeUserIdManager(currentUser, Arrays.asList(otherUser1, managedUser1,
+ personalUser));
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
+
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ }
+
+ @Test
+ public void testGetUserIds_otherUserAndManagedUser_currentUserIsManagedUser() {
+ // When there is no system user, returns the current user.
+ // This is a case theoretically can happen but we don't expect. So we return the current
+ // user only.
+ UserId currentUser = UserId.of(managedUser1);
+ initializeUserIdManager(currentUser, Arrays.asList(otherUser1, managedUser1,
+ personalUser));
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isTrue();
+
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ }
+
+ @Test
+ public void testUserIds_personalUserAndManagedUser_returnCachedList() {
+ UserId currentUser = UserId.of(personalUser);
+ initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
+ assertThat(userIdManager.getPersonalUserId()).isSameInstanceAs(
+ userIdManager.getPersonalUserId());
+ assertThat(userIdManager.getManagedUserId()).isSameInstanceAs(
+ userIdManager.getManagedUserId());
+ }
+
+ private void initializeUserIdManager(UserId current, List<UserHandle> usersOnDevice) {
+ when(mockUserManager.getUserProfiles()).thenReturn(usersOnDevice);
+ userIdManager = new UserIdManager.RuntimeUserIdManager(mockContext, current);
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java b/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java
new file mode 100644
index 0000000..6c74140
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.data.model;
+
+import static com.android.providers.media.photopicker.data.model.Item.ItemColumns;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ItemTest {
+
+ @Test
+ public void testConstructor() {
+ final long id = 1;
+ final long dateTaken = 12345678l;
+ final String mimeType = "image/png";
+ final String displayName = "123.png";
+ final String volumeName = "primary";
+ final long duration = 1000;
+
+ final Cursor cursor = generateCursorForItem(id, mimeType, displayName, volumeName,
+ dateTaken, duration);
+ cursor.moveToFirst();
+
+ final Item item = new Item(cursor, UserId.CURRENT_USER);
+
+ assertThat(item.getId()).isEqualTo(id);
+ assertThat(item.getDateTaken()).isEqualTo(dateTaken);
+ assertThat(item.getDisplayName()).isEqualTo(displayName);
+ assertThat(item.getMimeType()).isEqualTo(mimeType);
+ assertThat(item.getVolumeName()).isEqualTo(volumeName);
+ assertThat(item.getDuration()).isEqualTo(duration);
+ }
+
+ private static Cursor generateCursorForItem(long id, String mimeType,
+ String displayName, String volumeName, long dateTaken, long duration) {
+ final MatrixCursor cursor = new MatrixCursor(
+ ItemColumns.ALL_COLUMNS_LIST.toArray(new String[0]));
+ cursor.addRow(new Object[] {id, mimeType, displayName, volumeName, dateTaken, duration});
+ return cursor;
+ }
+}