diff options
author | Xin Li <delphij@google.com> | 2024-01-26 14:06:58 -0800 |
---|---|---|
committer | Xin Li <delphij@google.com> | 2024-01-26 14:06:58 -0800 |
commit | 6a96d097cc1076180a8b7b1e218da5346c2e2442 (patch) | |
tree | bd95a632084706124ebbc0906a9cff2e406d1379 | |
parent | ae696e6e3bac3459ea4d5b75eb700bd267ae49cf (diff) | |
parent | fc5f33dc5ac601e5bbade4f78318e2ab03ca8715 (diff) | |
download | MediaProvider-6a96d097cc1076180a8b7b1e218da5346c2e2442.tar.gz |
Merge Android 24Q1 Release (ab/11220357)
Bug: 319669529
Merged-In: Id98d1ea9ea7d63cbeece73e59f36fdc3c7d561e0
Change-Id: Icb0cdfade56f7cff95bbd7b0ed5fa0fbdf84a72a
311 files changed, 21725 insertions, 4088 deletions
diff --git a/Android.bp b/Android.bp index 044dbd749..6402ce280 100644 --- a/Android.bp +++ b/Android.bp @@ -20,16 +20,20 @@ android_app { "modules-utils-build", "modules-utils-uieventlogger-interface", "glide-prebuilt", + "glide-integration-recyclerview-prebuilt", + "glide-integration-webpdecoder-prebuilt", "glide-gifdecoder-prebuilt", "glide-disklrucache-prebuilt", "glide-annotation-and-compiler-prebuilt", "androidx.fragment_fragment", "androidx.vectordrawable_vectordrawable-animated", "androidx.exifinterface_exifinterface", + "androidx.work_work-runtime", "exoplayer-mediaprovider-ui", "modules-utils-shell-command-handler", "SettingsLibProfileSelector", "SettingsLibSelectorWithWidgetPreference", + "mediaprovider_flags_java_lib", ], libs: [ @@ -117,7 +121,6 @@ filegroup { java_library { name: "mediaprovider-database", srcs: [ - "src/com/android/providers/media/LegacyDatabaseHelper.java", "src/com/android/providers/media/util/DatabaseUtils.java", "src/com/android/providers/media/util/FileUtils.java", "src/com/android/providers/media/util/ForegroundThread.java", @@ -168,3 +171,18 @@ sh_binary { name: "media_provider", src: "cli/media_provider_cli_wrapper.sh", } + +aconfig_declarations { + name: "mediaprovider_flags", + package: "com.android.providers.media.flags", + srcs: ["mediaprovider_flags.aconfig"], +} + +java_aconfig_library { + name: "mediaprovider_flags_java_lib", + aconfig_declarations: "mediaprovider_flags", + min_sdk_version: "30", + apex_available: [ + "com.android.mediaprovider", + ], +}
\ No newline at end of file diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 43cc9692d..8884bd950 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,4 +1,5 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" package="com.android.providers.media.module"> <meta-data @@ -91,6 +92,18 @@ android:authorities="com.android.providers.media.remote_video_preview" android:exported="false" /> + <!-- Don't initialise WorkManager by default at startup --> + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + android:exported="false" + tools:node="merge"> + <meta-data + android:name="androidx.work.WorkManagerInitializer" + android:value="androidx.startup" + tools:node="remove" /> + </provider> + <!-- Handles database upgrades after OTAs, then disables itself --> <receiver android:name="com.android.providers.media.MediaUpgradeReceiver" android:exported="true"> diff --git a/TEST_MAPPING b/TEST_MAPPING index 08df77c08..5d49e6285 100644 --- a/TEST_MAPPING +++ b/TEST_MAPPING @@ -1,7 +1,13 @@ { "mainline-presubmit": [ { - "name": "MediaProviderTests[com.google.android.mediaprovider.apex]" + "name": "MediaProviderTests[com.google.android.mediaprovider.apex]", + "options": [ + { + // Ignore the tests with @RunOnlyOnPostsubmit annotation + "exclude-annotation": "com.android.providers.media.library.RunOnlyOnPostsubmit" + } + ] }, { "name": "CtsScopedStorageCoreHostTest[com.google.android.mediaprovider.apex]" @@ -13,20 +19,27 @@ "name": "CtsScopedStorageDeviceOnlyTest[com.google.android.mediaprovider.apex]" }, { - "name": "CtsMediaProviderTranscodeTests[com.google.android.mediaprovider.apex]" + "name": "CtsScopedStorageBypassDatabaseOperationsTest[com.google.android.mediaprovider.apex]" }, { - "name": "CtsPhotoPickerTest[com.google.android.mediaprovider.apex]", - "options": [ - { - "exclude-annotation": "androidx.test.filters.LargeTest" - } - ] + "name": "CtsScopedStorageGeneralTest[com.google.android.mediaprovider.apex]" + }, + { + "name": "CtsScopedStorageRedactUriTest[com.google.android.mediaprovider.apex]" + }, + { + "name": "CtsMediaProviderTranscodeTests[com.google.android.mediaprovider.apex]" } ], "presubmit": [ { - "name": "MediaProviderTests" + "name": "MediaProviderTests", + "options": [ + { + // Ignore the tests with @RunOnlyOnPostsubmit annotation + "exclude-annotation": "com.android.providers.media.library.RunOnlyOnPostsubmit" + } + ] }, { "name": "MediaProviderClientTests", @@ -62,15 +75,16 @@ "name": "CtsScopedStorageDeviceOnlyTest" }, { - "name": "fuse_node_test" + "name": "CtsScopedStorageBypassDatabaseOperationsTest" }, { - "name": "CtsPhotoPickerTest", - "options": [ - { - "exclude-annotation": "androidx.test.filters.LargeTest" - } - ] + "name": "CtsScopedStorageGeneralTest" + }, + { + "name": "CtsScopedStorageRedactUriTest" + }, + { + "name": "fuse_node_test" } ], "postsubmit": [ @@ -82,7 +96,7 @@ "name": "CtsMediaProviderTranscodeTests" }, { - "name": "CtsAppSecurityHostTestCases", + "name": "CtsStorageHostTestCases", "options": [ { "include-filter": "android.appsecurity.cts.ExternalStorageHostTest" @@ -91,6 +105,34 @@ }, { "name": "CtsPhotoPickerTest" + }, + { + "name": "MediaProviderTests", + "options": [ + { + // Only execute the tests with @RunOnlyOnPostsubmit annotation + "include-annotation": "com.android.providers.media.library.RunOnlyOnPostsubmit" + } + ] + } + ], + "mainline-postsubmit": [ + { + "name": "MediaProviderTests[com.google.android.mediaprovider.apex]", + "options": [ + { + // Only execute the tests with @RunOnlyOnPostsubmit annotation + "include-annotation": "com.android.providers.media.library.RunOnlyOnPostsubmit" + } + ] + }, + { + "name": "CtsPhotoPickerTest[com.google.android.mediaprovider.apex]", + "options": [ + { + "exclude-annotation": "androidx.test.filters.LargeTest" + } + ] } ] } diff --git a/apex/Android.bp b/apex/Android.bp index 328307909..5a8d1a186 100644 --- a/apex/Android.bp +++ b/apex/Android.bp @@ -7,7 +7,7 @@ apex { name: "com.android.mediaprovider", defaults: ["com.android.mediaprovider-defaults"], manifest: "apex_manifest.json", - apps: ["MediaProvider"], + apps: ["MediaProvider", "PdfViewer"], compat_configs: ["media-provider-platform-compat-config"], } diff --git a/apex/framework/api/current.txt b/apex/framework/api/current.txt index b0d655dc2..cddfcfae6 100644 --- a/apex/framework/api/current.txt +++ b/apex/framework/api/current.txt @@ -56,6 +56,7 @@ package android.provider { field public static final String EXTRA_ALBUM_ID = "android.provider.extra.ALBUM_ID"; field public static final String EXTRA_LOOPING_PLAYBACK_ENABLED = "android.provider.extra.LOOPING_PLAYBACK_ENABLED"; field public static final String EXTRA_MEDIA_COLLECTION_ID = "android.provider.extra.MEDIA_COLLECTION_ID"; + field public static final String EXTRA_PAGE_SIZE = "android.provider.extra.PAGE_SIZE"; field public static final String EXTRA_PAGE_TOKEN = "android.provider.extra.PAGE_TOKEN"; field public static final String EXTRA_PREVIEW_THUMBNAIL = "android.provider.extra.PREVIEW_THUMBNAIL"; field public static final String EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED = "android.provider.extra.SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED"; @@ -149,6 +150,7 @@ package android.provider { 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 @FlaggedApi("com.android.providers.media.flags.pick_ordered_images") public static final String EXTRA_PICK_IMAGES_IN_ORDER = "android.provider.extra.PICK_IMAGES_IN_ORDER"; field public static final String EXTRA_PICK_IMAGES_MAX = "android.provider.extra.PICK_IMAGES_MAX"; 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"; diff --git a/apex/framework/api/lint-baseline.txt b/apex/framework/api/lint-baseline.txt new file mode 100644 index 000000000..1ed25add8 --- /dev/null +++ b/apex/framework/api/lint-baseline.txt @@ -0,0 +1,5 @@ +// Baseline format: 1.0 +RequiresPermission: android.provider.MediaStore#canManageMedia(android.content.Context): + Method 'canManageMedia' documentation mentions permissions without declaring @RequiresPermission +RequiresPermission: android.provider.MediaStore#setRequireOriginal(android.net.Uri): + Method 'setRequireOriginal' documentation mentions permissions without declaring @RequiresPermission diff --git a/apex/framework/api/module-lib-lint-baseline.txt b/apex/framework/api/module-lib-lint-baseline.txt new file mode 100644 index 000000000..44629d8ac --- /dev/null +++ b/apex/framework/api/module-lib-lint-baseline.txt @@ -0,0 +1,9 @@ +// Baseline format: 1.0 +RequiresPermission: android.provider.MediaStore#canManageMedia(android.content.Context): + Method 'canManageMedia' documentation mentions permissions without declaring @RequiresPermission +RequiresPermission: android.provider.MediaStore#setRequireOriginal(android.net.Uri): + Method 'setRequireOriginal' documentation mentions permissions without declaring @RequiresPermission + + +SdkConstant: android.provider.MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP: + Field 'ACTION_USER_SELECT_IMAGES_FOR_APP' is missing @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION) diff --git a/apex/framework/api/system-lint-baseline.txt b/apex/framework/api/system-lint-baseline.txt new file mode 100644 index 000000000..44629d8ac --- /dev/null +++ b/apex/framework/api/system-lint-baseline.txt @@ -0,0 +1,9 @@ +// Baseline format: 1.0 +RequiresPermission: android.provider.MediaStore#canManageMedia(android.content.Context): + Method 'canManageMedia' documentation mentions permissions without declaring @RequiresPermission +RequiresPermission: android.provider.MediaStore#setRequireOriginal(android.net.Uri): + Method 'setRequireOriginal' documentation mentions permissions without declaring @RequiresPermission + + +SdkConstant: android.provider.MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP: + Field 'ACTION_USER_SELECT_IMAGES_FOR_APP' is missing @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION) diff --git a/apex/framework/java/android/provider/CloudMediaProvider.java b/apex/framework/java/android/provider/CloudMediaProvider.java index 924ec028f..665739e89 100644 --- a/apex/framework/java/android/provider/CloudMediaProvider.java +++ b/apex/framework/java/android/provider/CloudMediaProvider.java @@ -206,6 +206,7 @@ public abstract class CloudMediaProvider extends ContentProvider { * <li> {@link CloudMediaProviderContract#EXTRA_SYNC_GENERATION} * <li> {@link CloudMediaProviderContract#EXTRA_PAGE_TOKEN} * <li> {@link CloudMediaProviderContract#EXTRA_ALBUM_ID} + * <li> {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE} * </ul> * @return cursor representing media items containing all * {@link CloudMediaProviderContract.MediaColumns} columns @@ -259,6 +260,7 @@ public abstract class CloudMediaProvider extends ContentProvider { * <ul> * <li> {@link CloudMediaProviderContract#EXTRA_SYNC_GENERATION} * <li> {@link CloudMediaProviderContract#EXTRA_PAGE_TOKEN} + * <li> {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE} * </ul> * @return cursor representing album items containing all * {@link CloudMediaProviderContract.AlbumColumns} columns @@ -270,7 +272,9 @@ public abstract class CloudMediaProvider extends ContentProvider { } /** - * Returns a thumbnail of {@code size} for a media item identified by {@code mediaId}. + * Returns a thumbnail of {@code size} for a media item identified by {@code mediaId} + * <p>The cloud media provider should strictly return thumbnail in the original + * {@link CloudMediaProviderContract.MediaColumns#MIME_TYPE} of the item. * <p> * This is expected to be a much lower resolution version than the item returned by * {@link #onOpenMedia}. diff --git a/apex/framework/java/android/provider/CloudMediaProviderContract.java b/apex/framework/java/android/provider/CloudMediaProviderContract.java index cd4b4340f..5e610a86c 100644 --- a/apex/framework/java/android/provider/CloudMediaProviderContract.java +++ b/apex/framework/java/android/provider/CloudMediaProviderContract.java @@ -88,8 +88,8 @@ public final class CloudMediaProviderContract { public static final String DATE_TAKEN_MILLIS = "date_taken_millis"; /** - * Number associated with a media item indicating what generation or batch the media item - * was synced into the media collection. + * Non-negative number associated with a media item indicating what generation or batch the + * media item was synced into the media collection. * <p> * Providers should associate a monotonically increasing sync generation number to each * media item which is expected to increase for each atomic modification on the media item. @@ -551,6 +551,22 @@ public final class CloudMediaProviderContract { public static final String EXTRA_ALBUM_ID = "android.provider.extra.ALBUM_ID"; /** + * The maximum number of query results that should be included in a batch when syncing metadata + * with cloud provider. + * + * This extra can be passed as a {@link Bundle} parameter to the media or album query methods. + * + * It is optional for the provider to honor this extra and return results at max page size. + * + * @see CloudMediaProvider#onQueryMedia + * @see CloudMediaProvider#onQueryAlbums + * + * <p> + * Type: INTEGER + */ + public static final String EXTRA_PAGE_SIZE = "android.provider.extra.PAGE_SIZE"; + + /** * Limits the query results to only media items less than the given file size in bytes. * <p> * This is only intended for the MediaProvider to implement for cross-user communication. Not diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java index 634d25f7f..b1172afd2 100644 --- a/apex/framework/java/android/provider/MediaStore.java +++ b/apex/framework/java/android/provider/MediaStore.java @@ -20,6 +20,7 @@ import android.annotation.BytesLong; import android.annotation.CurrentTimeMillisLong; import android.annotation.CurrentTimeSecondsLong; import android.annotation.DurationMillisLong; +import android.annotation.FlaggedApi; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; @@ -260,6 +261,8 @@ public final class MediaStore { /** {@hide} */ public static final String GET_CLOUD_PROVIDER_RESULT = "get_cloud_provider_result"; /** {@hide} */ + public static final String SET_CLOUD_PROVIDER_RESULT = "set_cloud_provider_result"; + /** {@hide} */ public static final String SET_CLOUD_PROVIDER_CALL = "set_cloud_provider"; /** {@hide} */ public static final String EXTRA_CLOUD_PROVIDER = "cloud_provider"; @@ -272,10 +275,22 @@ public final class MediaStore { public static final String GRANT_MEDIA_READ_FOR_PACKAGE_CALL = "grant_media_read_for_package"; + /** @hide */ + public static final String REVOKE_READ_GRANT_FOR_PACKAGE_CALL = + "revoke_media_read_for_package"; + /** {@hide} */ public static final String USES_FUSE_PASSTHROUGH = "uses_fuse_passthrough"; /** {@hide} */ public static final String USES_FUSE_PASSTHROUGH_RESULT = "uses_fuse_passthrough_result"; + /** {@hide} */ + public static final String PICKER_MEDIA_INIT_CALL = "picker_media_init"; + /** {@hide} */ + public static final String EXTRA_LOCAL_ONLY = "is_local_only"; + /** {@hide} */ + public static final String EXTRA_ALBUM_ID = "album_id"; + /** {@hide} */ + public static final String EXTRA_ALBUM_AUTHORITY = "album_authority"; /** * Only used for testing. @@ -290,7 +305,14 @@ public final class MediaStore { * {@hide} */ @VisibleForTesting - public static final String READ_BACKED_UP_FILE_PATHS = "read_backed_up_file_paths"; + public static final String READ_BACKUP = "read_backup"; + + /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting + public static final String GET_OWNER_PACKAGE_NAME = "get_owner_package_name"; /** * Only used for testing. @@ -304,6 +326,20 @@ public final class MediaStore { * {@hide} */ @VisibleForTesting + public static final String GET_RECOVERY_DATA = "get_recovery_data"; + + /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting + public static final String REMOVE_RECOVERY_DATA = "remove_recovery_data"; + + /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting public static final String DELETE_BACKED_UP_FILE_PATHS = "delete_backed_up_file_paths"; /** {@hide} */ @@ -482,18 +518,18 @@ public final class MediaStore { public static final String EXTRA_SHOW_ACTION_ICONS = "android.intent.extra.showActionIcons"; /** - * The name of the Intent-extra used to control the onCompletion behavior of a MovieView. - * This is a boolean property that specifies whether or not to finish the MovieView activity - * when the movie completes playing. The default value is true, which means to automatically - * exit the movie player activity when the movie completes playing. + * The name of the Intent-extra used to control the onCompletion behavior of a MovieView. This + * is a boolean property that specifies whether or not to finish the MovieView activity when the + * movie completes playing. The default value is true, which means to automatically exit the + * movie player activity when the movie completes playing. */ - public static final String EXTRA_FINISH_ON_COMPLETION = "android.intent.extra.finishOnCompletion"; + public static final String EXTRA_FINISH_ON_COMPLETION = + "android.intent.extra.finishOnCompletion"; - /** - * The name of the Intent action used to launch a camera in still image mode. - */ + /** The name of the Intent action used to launch a camera in still image mode. */ @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION) - public static final String INTENT_ACTION_STILL_IMAGE_CAMERA = "android.media.action.STILL_IMAGE_CAMERA"; + public static final String INTENT_ACTION_STILL_IMAGE_CAMERA = + "android.media.action.STILL_IMAGE_CAMERA"; /** * Name under which an activity handling {@link #INTENT_ACTION_STILL_IMAGE_CAMERA} or @@ -720,42 +756,39 @@ public final class MediaStore { 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 + * 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 the 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 + * <li>the data for this action is provided by the 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 - * {@code video/*}), 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 MediaStore#EXTRA_PICK_IMAGES_MAX} to indicate this. - * <p> - * When the caller requests multiple selection, the value of - * {@link MediaStore#EXTRA_PICK_IMAGES_MAX} must be a positive integer - * greater than 1 and less than or equal to - * {@link MediaStore#getPickImagesMaxLimit}, otherwise - * {@link Activity#RESULT_CANCELED} is returned. - * <p> - * Callers may use {@link Intent#EXTRA_LOCAL_ONLY} to limit content - * selection to local data. - * <p> - * Output: MediaStore content URI(s) of the item(s) that was picked. - * Unlike other MediaStore URIs, these are referred to as 'picker' URIs and - * expose a limited set of read-only operations. Specifically, picker URIs - * can only be opened for read and queried for columns in {@link PickerMediaColumns}. - * <p> - * Before this API, apps could use {@link Intent#ACTION_GET_CONTENT}. However, - * {@link #ACTION_PICK_IMAGES} is now the recommended option for images and videos, - * since it offers a better user experience. + * + * <p>Callers can optionally specify MIME type (such as {@code image/*} or {@code video/*}), + * 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 MediaStore#EXTRA_PICK_IMAGES_MAX} to indicate this. + * + * <p>When the caller requests multiple selection, the value of {@link + * MediaStore#EXTRA_PICK_IMAGES_MAX} must be a positive integer greater than 1 and less than or + * equal to {@link MediaStore#getPickImagesMaxLimit}, otherwise {@link Activity#RESULT_CANCELED} + * is returned. Use {@link MediaStore#EXTRA_PICK_IMAGES_IN_ORDER} in multiple selection mode to + * allow the user to pick images in order. + * + * <p>Callers may use {@link Intent#EXTRA_LOCAL_ONLY} to limit content selection to local data. + * + * <p>Output: MediaStore content URI(s) of the item(s) that was picked. Unlike other MediaStore + * URIs, these are referred to as 'picker' URIs and expose a limited set of read-only + * operations. Specifically, picker URIs can only be opened for read and queried for columns in + * {@link PickerMediaColumns}. + * + * <p>Before this API, apps could use {@link Intent#ACTION_GET_CONTENT}. However, {@link + * #ACTION_PICK_IMAGES} is now the recommended option for images and videos, since it offers a + * better user experience. */ @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION) public static final String ACTION_PICK_IMAGES = "android.provider.action.PICK_IMAGES"; @@ -803,6 +836,20 @@ public final class MediaStore { "android.provider.action.PICK_IMAGES_SETTINGS"; /** + * The name of an optional intent-extra used to allow ordered selection of items. Set this extra + * to true to allow the user to see the order of their selected items. The result returned to + * the caller will be the same as the user selected order. This extra is only allowed via the + * {@link MediaStore#ACTION_PICK_IMAGES}. + * + * <p>The value of this intent-extra should be a boolean. Default value is false. + * + * @see #ACTION_PICK_IMAGES + */ + @FlaggedApi("com.android.providers.media.flags.pick_ordered_images") + public static final String EXTRA_PICK_IMAGES_IN_ORDER = + "android.provider.extra.PICK_IMAGES_IN_ORDER"; + + /** * The name of an optional intent-extra used to allow multiple selection of * items and constrain maximum number of items that can be returned by * {@link MediaStore#ACTION_PICK_IMAGES}, action may still return nothing @@ -4721,10 +4768,23 @@ public final class MediaStore { * {@hide} */ @VisibleForTesting - public static String[] readBackedUpFilePaths(@NonNull ContentResolver resolver, - String volumeName) { - Bundle bundle = resolver.call(AUTHORITY, READ_BACKED_UP_FILE_PATHS, volumeName, null); - return bundle.getStringArray(READ_BACKED_UP_FILE_PATHS); + public static String readBackup(@NonNull ContentResolver resolver, + String volumeName, String filePath) { + Bundle extras = new Bundle(); + extras.putString(Files.FileColumns.DATA, filePath); + Bundle bundle = resolver.call(AUTHORITY, READ_BACKUP, volumeName, extras); + return bundle.getString(READ_BACKUP); + } + + /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting + public static String getOwnerPackageName(@NonNull ContentResolver resolver, int ownerId) { + Bundle bundle = resolver.call(AUTHORITY, GET_OWNER_PACKAGE_NAME, String.valueOf(ownerId), + null); + return bundle.getString(GET_OWNER_PACKAGE_NAME); } /** @@ -4748,6 +4808,25 @@ public final class MediaStore { } /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting + public static String[] getRecoveryData(@NonNull ContentResolver resolver) { + Bundle bundle = resolver.call(AUTHORITY, GET_RECOVERY_DATA, null, null); + return bundle.getStringArray(GET_RECOVERY_DATA); + } + + /** + * Only used for testing. + * {@hide} + */ + @VisibleForTesting + public static void removeRecoveryData(@NonNull ContentResolver resolver) { + resolver.call(AUTHORITY, REMOVE_RECOVERY_DATA, null, null); + } + + /** * Block until any pending operations have finished, such as * {@link #scanFile} or {@link #scanVolume} requests. * @@ -4914,4 +4993,29 @@ public final class MediaStore { throw e.rethrowAsRuntimeException(); } } + + /** + * Revoke {@link com.android.providers.media.MediaGrants} for the given package, for the + * list of local (to the device) content uris. These must be valid picker uris. + * + * @hide + */ + public static void revokeMediaReadForPackages( + @NonNull Context context, int packageUid, @NonNull List<Uri> uris) { + Objects.requireNonNull(uris); + if (uris.isEmpty()) { + return; + } + final ContentResolver resolver = context.getContentResolver(); + try (ContentProviderClient client = resolver.acquireContentProviderClient(AUTHORITY)) { + final Bundle extras = new Bundle(); + extras.putInt(Intent.EXTRA_UID, packageUid); + extras.putParcelableArrayList(EXTRA_URI_LIST, new ArrayList<Uri>(uris)); + client.call(REVOKE_READ_GRANT_FOR_PACKAGE_CALL, + /* arg= */ null, + /* extras= */ extras); + } catch (RemoteException e) { + throw e.rethrowAsRuntimeException(); + } + } } diff --git a/jni/FuseDaemon.cpp b/jni/FuseDaemon.cpp index 68a1463a0..1c81fbade 100644 --- a/jni/FuseDaemon.cpp +++ b/jni/FuseDaemon.cpp @@ -1984,7 +1984,9 @@ static void pf_readdir_postfilter(fuse_req_t req, fuse_ino_t ino, uint32_t error struct fuse_dirent* dirent_out = (struct fuse_dirent*)((char*)dirents_out + fro->size); struct stat stats; int err; - std::string child_path = path + "/" + dirent_in->name; + + std::string child_name(dirent_in->name, dirent_in->namelen); + std::string child_path = path + "/" + child_name; in += sizeof(*dirent_in) + round_up(dirent_in->namelen, sizeof(uint64_t)); err = stat(child_path.c_str(), &stats); @@ -1992,9 +1994,9 @@ static void pf_readdir_postfilter(fuse_req_t req, fuse_ino_t ino, uint32_t error ((stats.st_mode & 0001) || ((stats.st_mode & 0010) && req->ctx.gid == stats.st_gid) || ((stats.st_mode & 0100) && req->ctx.uid == stats.st_uid) || fuse->mp->isUidAllowedAccessToDataOrObbPath(req->ctx.uid, child_path) || - strcmp(dirent_in->name, ".nomedia") == 0)) { + child_name == ".nomedia")) { *dirent_out = *dirent_in; - strcpy(dirent_out->name, dirent_in->name); + strcpy(dirent_out->name, child_name.c_str()); fro->size += sizeof(*dirent_out) + round_up(dirent_out->namelen, sizeof(uint64_t)); } } @@ -2580,6 +2582,16 @@ void FuseDaemon::SetupLevelDbInstances() { } } +void FuseDaemon::SetupPublicVolumeLevelDbInstance(const std::string& volume_name) { + if (android::base::StartsWith(fuse->root->GetIoPath(), PRIMARY_VOLUME_PREFIX)) { + // Setup leveldb instance for both external primary and internal volume. + fuse->level_db_mutex.lock(); + // Create level db instance for public volume + SetupLevelDbConnection(volume_name); + fuse->level_db_mutex.unlock(); + } +} + std::string deriveVolumeName(const std::string& path) { std::string volume_name; if (!android::base::StartsWith(path, STORAGE_PREFIX)) { @@ -2587,8 +2599,10 @@ std::string deriveVolumeName(const std::string& path) { } else if (android::base::StartsWith(path, PRIMARY_VOLUME_PREFIX)) { volume_name = VOLUME_EXTERNAL_PRIMARY; } else { - size_t size = sizeof(STORAGE_PREFIX) / sizeof(STORAGE_PREFIX[0]); - volume_name = volume_name.substr(size); + // Return "C58E-1702" from the path like "/storage/C58E-1702/Download/1935694997673.png" + volume_name = path.substr(9, 9); + // Convert to lowercase + std::transform(volume_name.begin(), volume_name.end(), volume_name.begin(), ::tolower); } return volume_name; } @@ -2596,7 +2610,7 @@ std::string deriveVolumeName(const std::string& path) { void FuseDaemon::DeleteFromLevelDb(const std::string& key) { std::string volume_name = deriveVolumeName(key); if (!CheckLevelDbConnection(volume_name)) { - LOG(ERROR) << "Failure in leveldb delete in volume:" << volume_name << " for key:" << key; + LOG(ERROR) << "DeleteFromLevelDb: Missing leveldb connection."; return; } @@ -2608,10 +2622,10 @@ void FuseDaemon::DeleteFromLevelDb(const std::string& key) { } } -void FuseDaemon::InsertInLevelDb(const std::string& key, const std::string& value) { - std::string volume_name = deriveVolumeName(key); +void FuseDaemon::InsertInLevelDb(const std::string& volume_name, const std::string& key, + const std::string& value) { if (!CheckLevelDbConnection(volume_name)) { - LOG(ERROR) << "Failure in leveldb insert in volume:" << volume_name << " for key:" << key; + LOG(ERROR) << "InsertInLevelDb: Missing leveldb connection."; return; } @@ -2619,6 +2633,7 @@ void FuseDaemon::InsertInLevelDb(const std::string& key, const std::string& valu status = fuse->level_db_connection_map[volume_name]->Put(leveldb::WriteOptions(), key, value); if (!status.ok()) { LOG(ERROR) << "Failure in leveldb insert for key: " << key << " in volume:" << volume_name; + LOG(ERROR) << status.ToString(); } } @@ -2629,7 +2644,7 @@ std::vector<std::string> FuseDaemon::ReadFilePathsFromLevelDb(const std::string& std::vector<std::string> file_paths; if (!CheckLevelDbConnection(volume_name)) { - LOG(ERROR) << "Failure in leveldb file paths read for volume:" << volume_name; + LOG(ERROR) << "ReadFilePathsFromLevelDb: Missing leveldb connection."; return file_paths; } @@ -2654,16 +2669,16 @@ std::string FuseDaemon::ReadBackedUpDataFromLevelDb(const std::string& filePath) std::string data = ""; std::string volume_name = deriveVolumeName(filePath); if (!CheckLevelDbConnection(volume_name)) { - LOG(ERROR) << "Failure in leveldb data read for key:" << filePath; + LOG(ERROR) << "ReadBackedUpDataFromLevelDb: Missing leveldb connection."; return data; } leveldb::Status status = fuse->level_db_connection_map[volume_name]->Get(leveldb::ReadOptions(), filePath, &data); - if (!status.ok()) { - LOG(WARNING) << "Failure in leveldb read for key: " << filePath << status.ToString(); - } else { - LOG(DEBUG) << "Read successful for key: " << filePath; + if (status.IsNotFound()) { + LOG(VERBOSE) << "Key is not found in leveldb: " << filePath << " " << status.ToString(); + } else if (!status.ok()) { + LOG(WARNING) << "Failure in leveldb read for key: " << filePath << " " << status.ToString(); } return data; } @@ -2671,22 +2686,26 @@ std::string FuseDaemon::ReadBackedUpDataFromLevelDb(const std::string& filePath) std::string FuseDaemon::ReadOwnership(const std::string& key) { // Return empty string if key not found std::string data = ""; - if (CheckLevelDbConnection(OWNERSHIP_RELATION)) { - leveldb::Status status = fuse->level_db_connection_map[OWNERSHIP_RELATION]->Get( - leveldb::ReadOptions(), key, &data); - if (!status.ok()) { - LOG(WARNING) << "Failure in leveldb read for key: " << key << status.ToString(); - } else { - LOG(DEBUG) << "Read successful for key: " << key; - } + if (!CheckLevelDbConnection(OWNERSHIP_RELATION)) { + LOG(ERROR) << "ReadOwnership: Missing leveldb connection."; + return data; + } + + leveldb::Status status = fuse->level_db_connection_map[OWNERSHIP_RELATION]->Get( + leveldb::ReadOptions(), key, &data); + if (status.IsNotFound()) { + LOG(VERBOSE) << "Key is not found in leveldb: " << key << " " << status.ToString(); + } else if (!status.ok()) { + LOG(WARNING) << "Failure in leveldb read for key: " << key << " " << status.ToString(); } + return data; } void FuseDaemon::CreateOwnerIdRelation(const std::string& ownerId, const std::string& ownerPackageIdentifier) { if (!CheckLevelDbConnection(OWNERSHIP_RELATION)) { - LOG(ERROR) << "Failure in leveldb insert for ownership relation."; + LOG(ERROR) << "CreateOwnerIdRelation: Missing leveldb connection."; return; } @@ -2709,7 +2728,7 @@ void FuseDaemon::CreateOwnerIdRelation(const std::string& ownerId, void FuseDaemon::RemoveOwnerIdRelation(const std::string& ownerId, const std::string& ownerPackageIdentifier) { if (!CheckLevelDbConnection(OWNERSHIP_RELATION)) { - LOG(ERROR) << "Failure in leveldb delete for ownership relation."; + LOG(ERROR) << "RemoveOwnerIdRelation: Missing leveldb connection."; return; } @@ -2735,7 +2754,7 @@ void FuseDaemon::RemoveOwnerIdRelation(const std::string& ownerId, std::map<std::string, std::string> FuseDaemon::GetOwnerRelationship() { std::map<std::string, std::string> resultMap; if (!CheckLevelDbConnection(OWNERSHIP_RELATION)) { - LOG(ERROR) << "Failure in leveldb read for ownership relation."; + LOG(ERROR) << "GetOwnerRelationship: Missing leveldb connection."; return resultMap; } @@ -2753,7 +2772,7 @@ std::map<std::string, std::string> FuseDaemon::GetOwnerRelationship() { bool FuseDaemon::CheckLevelDbConnection(const std::string& instance_name) { if (fuse->level_db_connection_map.find(instance_name) == fuse->level_db_connection_map.end()) { - LOG(ERROR) << "Leveldb setup is missing for :" << instance_name; + LOG(ERROR) << "Leveldb setup is missing for: " << instance_name; return false; } return true; diff --git a/jni/FuseDaemon.h b/jni/FuseDaemon.h index a634812d2..a9eaf2225 100644 --- a/jni/FuseDaemon.h +++ b/jni/FuseDaemon.h @@ -80,6 +80,11 @@ class FuseDaemon final { void SetupLevelDbInstances(); /** + * Setup leveldb instances for public volume. + */ + void SetupPublicVolumeLevelDbInstance(const std::string& volume_name); + + /** * Creates a leveldb instance and sets up a connection. */ void SetupLevelDbConnection(const std::string& instance_name); @@ -90,9 +95,10 @@ class FuseDaemon final { void DeleteFromLevelDb(const std::string& key); /** - * Inserts in leveldb instance of volume derived from path. + * Inserts in leveldb instance of provided volume. */ - void InsertInLevelDb(const std::string& key, const std::string& value); + void InsertInLevelDb(const std::string& volume_name, const std::string& key, + const std::string& value); /** * Reads file paths for given volume from leveldb for given range. diff --git a/jni/com_android_providers_media_FuseDaemon.cpp b/jni/com_android_providers_media_FuseDaemon.cpp index 97a6a6e0f..2b7864569 100644 --- a/jni/com_android_providers_media_FuseDaemon.cpp +++ b/jni/com_android_providers_media_FuseDaemon.cpp @@ -196,6 +196,18 @@ void com_android_providers_media_FuseDaemon_setup_volume_db_backup(JNIEnv* env, daemon->SetupLevelDbInstances(); } +void com_android_providers_media_FuseDaemon_setup_public_volume_db_backup(JNIEnv* env, jobject self, + jlong java_daemon, + jstring volume_name) { + fuse::FuseDaemon* const daemon = reinterpret_cast<fuse::FuseDaemon*>(java_daemon); + ScopedUtfChars utf_chars_volumeName(env, volume_name); + if (!utf_chars_volumeName.c_str()) { + LOG(WARNING) << "Couldn't initialise FUSE device id for " << volume_name; + return; + } + daemon->SetupPublicVolumeLevelDbInstance(utf_chars_volumeName.c_str()); +} + void com_android_providers_media_FuseDaemon_delete_db_backup(JNIEnv* env, jobject self, jlong java_daemon, jstring java_path) { fuse::FuseDaemon* const daemon = reinterpret_cast<fuse::FuseDaemon*>(java_daemon); @@ -209,15 +221,19 @@ void com_android_providers_media_FuseDaemon_delete_db_backup(JNIEnv* env, jobjec void com_android_providers_media_FuseDaemon_backup_volume_db_data(JNIEnv* env, jobject self, jlong java_daemon, - jstring java_path, jstring value) { + jstring volume_name, + jstring java_path, + jstring value) { fuse::FuseDaemon* const daemon = reinterpret_cast<fuse::FuseDaemon*>(java_daemon); ScopedUtfChars utf_chars_path(env, java_path); ScopedUtfChars utf_chars_value(env, value); + ScopedUtfChars utf_chars_volumeName(env, volume_name); if (!utf_chars_path.c_str()) { LOG(WARNING) << "Couldn't initialise FUSE device id"; return; } - daemon->InsertInLevelDb(utf_chars_path.c_str(), utf_chars_value.c_str()); + daemon->InsertInLevelDb(utf_chars_volumeName.c_str(), utf_chars_path.c_str(), + utf_chars_value.c_str()); } bool com_android_providers_media_FuseDaemon_is_fuse_thread(JNIEnv* env, jclass clazz) { @@ -328,9 +344,11 @@ const JNINativeMethod methods[] = { reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_initialize_device_id)}, {"native_setup_volume_db_backup", "(J)V", reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_setup_volume_db_backup)}, + {"native_setup_public_volume_db_backup", "(JLjava/lang/String;)V", + reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_setup_public_volume_db_backup)}, {"native_delete_db_backup", "(JLjava/lang/String;)V", reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_delete_db_backup)}, - {"native_backup_volume_db_data", "(JLjava/lang/String;Ljava/lang/String;)V", + {"native_backup_volume_db_data", "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_backup_volume_db_data)}, {"native_read_backed_up_file_paths", "(JLjava/lang/String;Ljava/lang/String;I)[Ljava/lang/String;", diff --git a/jni/node.cpp b/jni/node.cpp index 31e497096..25f732d38 100644 --- a/jni/node.cpp +++ b/jni/node.cpp @@ -115,6 +115,14 @@ void node::DeleteTree(node* tree) { std::lock_guard<std::recursive_mutex> guard(*tree->lock_); if (tree) { + // Guarantee this node not be released while deleting its children. + // pf_forget could be called for a parent node first not its children + // when evicting file system inodes by shrinker, so the parent node + // could exist without its own reference but having a children node. + // In this case, this node could be deleted during executing + // DeleteTree(child), and it causes double free for the node. + tree->Acquire(); + // Make a copy of the list of children because calling Delete tree // will modify the list of children, which will cause issues while // iterating over them. diff --git a/jni/node_test.cpp b/jni/node_test.cpp index f687cad89..6afdd75b3 100644 --- a/jni/node_test.cpp +++ b/jni/node_test.cpp @@ -526,6 +526,22 @@ TEST_F(NodeTest, LookupChildByName_ChildrenWithSameName) { test_fn("BaZ", baz1.get(), baz2.get()); } +TEST_F(NodeTest, DestroyDoesntDoubleFree) { + node* root = node::Create(nullptr, "root", "", true, 0, 0, &lock_, 0, &tracker_); + node* child = node::Create(root, "child", "", true, 0, 0, &lock_, 0, &tracker_); + node* grandchild = node::Create(child, "grandchild", "", true, 0, 0, &lock_, 0, &tracker_); + + // 'child' is referenced by itself and by 'grandchild' + ASSERT_EQ(2, GetRefCount(child)); + // Kernel forgets about child only + ASSERT_FALSE(child->Release(1)); + // Child only referenced by 'grandchild' + ASSERT_EQ(1, GetRefCount(child)); + + // Now, destroying the filesystem shouldn't result in a double free + node::DeleteTree(root); +} + TEST_F(NodeTest, ForChild) { unique_node_ptr parent = CreateNode(nullptr, "/path"); unique_node_ptr foo1 = CreateNode(parent.get(), "FoO"); diff --git a/src/com/android/providers/media/LegacyDatabaseHelper.java b/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java index 0e218788b..9f85763b6 100644 --- a/src/com/android/providers/media/LegacyDatabaseHelper.java +++ b/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2023 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. @@ -1103,3 +1103,4 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea return "LegacyDH[" + getDatabaseName() + "]." + method; } } + diff --git a/mediaprovider_flags.aconfig b/mediaprovider_flags.aconfig new file mode 100644 index 000000000..85bc07b4b --- /dev/null +++ b/mediaprovider_flags.aconfig @@ -0,0 +1,8 @@ +package: "com.android.providers.media.flags" + +flag { + name: "pick_ordered_images" + namespace: "mediaprovider" + description: "This flag controls whether to enable ordered selection in photopicker" + bug: "303784642" +} diff --git a/mediaproviderutils.sh b/mediaproviderutils.sh index f25640d3d..f0b3dc63f 100644 --- a/mediaproviderutils.sh +++ b/mediaproviderutils.sh @@ -1,5 +1,6 @@ # Shell utility functions for mediaprovider developers. # sudo apt-get install rlwrap to have a more fully featured sqlite CLI +# sudo apt-get install sqlitebrowser to navigate the database with a GUI set -x # enable debugging function add-media-grant () { @@ -63,14 +64,9 @@ EOF fi } -function sqlite3-pull () { - adb root - if [ -z "$1" ] - then - dir=$(pwd) - else - dir=$1 - fi +function media-pull () { + adb root && adb wait-for-device + dir=$(get-dir $1) package=$(get-package) if [ -f "$dir/external.db" ]; then @@ -86,10 +82,21 @@ function sqlite3-pull () { sqlite3 $dir/external.db "drop trigger files_insert" sqlite3 $dir/external.db "drop trigger files_update" sqlite3 $dir/external.db "drop trigger files_delete" +} - rlwrap sqlite3 $dir/external.db +function sqlite3-pull () { + dir="$(get-dir $1)" + media-pull "$dir" + rlwrap sqlite3 "$dir"/external.db } +function sqlitebrowser-pull () { + dir="$(get-dir "$1")" + media-pull "$dir" + sqlitebrowser "$dir"/external.db +} + + function sqlite3-push () { adb root if [ -z "$1" ] @@ -145,6 +152,16 @@ function get-data-from-id () { adb shell sqlite3 $dir $clause } +function get-dir (){ + if [ -z "$1" ] + then + dir=$(pwd) + else + dir=$1 + fi + echo "$dir" +} + function get-package() { if [ -z "$(adb shell pm list package com.android.providers.media.module)" ] then diff --git a/pdf/apk/Android.bp b/pdf/apk/Android.bp new file mode 100644 index 000000000..42e5f606f --- /dev/null +++ b/pdf/apk/Android.bp @@ -0,0 +1,40 @@ +// Copyright (C) 2023 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 { + // See: http://go/android-license-faq + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_app_certificate { + name: "com.android.graphics.pdf.certificate", + certificate: "com.android.graphics.pdf", +} + +filegroup { + name: "pdfviewer-sources", + srcs: [ + "src/**/*.java", + ], +} + +android_app { + name: "PdfViewer", + srcs: [":pdfviewer-sources"], + updatable: true, + certificate: ":com.android.graphics.pdf.certificate", + sdk_version: "module_current", + min_sdk_version: "30", + apex_available: ["com.android.mediaprovider"], +}
\ No newline at end of file diff --git a/pdf/apk/AndroidManifest.xml b/pdf/apk/AndroidManifest.xml new file mode 100644 index 000000000..369bb561c --- /dev/null +++ b/pdf/apk/AndroidManifest.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2023 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.graphics.pdf"> + <application + android:label="PdfViewer"> + + </application> +</manifest>
\ No newline at end of file diff --git a/pdf/apk/com.android.graphics.pdf.pk8 b/pdf/apk/com.android.graphics.pdf.pk8 Binary files differnew file mode 100644 index 000000000..6004032cb --- /dev/null +++ b/pdf/apk/com.android.graphics.pdf.pk8 diff --git a/pdf/apk/com.android.graphics.pdf.x509.pem b/pdf/apk/com.android.graphics.pdf.x509.pem new file mode 100644 index 000000000..16e162877 --- /dev/null +++ b/pdf/apk/com.android.graphics.pdf.x509.pem @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +MIIGETCCA/mgAwIBAgIUP9mjBKPNP1+BI6mngJol0t6leZIwDQYJKoZIhvcNAQEL +BQAwgZYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH +DA1Nb3VudGFpbiBWaWV3MRAwDgYDVQQKDAdBbmRyb2lkMRAwDgYDVQQLDAdBbmRy +b2lkMRIwEAYDVQQDDAlQZGZWaWV3ZXIxIjAgBgkqhkiG9w0BCQEWE2FuZHJvaWRA +YW5kcm9pZC5jb20wIBcNMjMxMDEyMDQzNjI0WhgPNDc2MTA5MDcwNDM2MjRaMIGW +MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91 +bnRhaW4gVmlldzEQMA4GA1UECgwHQW5kcm9pZDEQMA4GA1UECwwHQW5kcm9pZDES +MBAGA1UEAwwJUGRmVmlld2VyMSIwIAYJKoZIhvcNAQkBFhNhbmRyb2lkQGFuZHJv +aWQuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmSU7yvmLqo/x +B4l9mrVeHxPeX9ok5cSkP9VoDPL/uTCKE7RFG7A6yIogwNMGL4aKLLUTpdDsJoKZ +bgZEHhJMuy/HE4XEaLJtzr9lO+Ys9RXgqKORZsBQrg/O5/pnepZvzSpZkpWiPHtm +HaREFe/j5pmjsjW3p9JYEbauawUvrg8KsBBAlPBwGckHsIuxQfPusW3EobqlXB0y +8mkT1UbJE+HRwWlazyfuR95vvGaS1g0dii03QVYTB6mNoGyM884YsSC95w/RtqYf +B7s9hbpUeefmjMILxD+ArJdiYHxFVD8j43tOvnc+y61Pz5o9zam6Rs+qLw7GNgfe +wOQg+BKMFc8JO2fiNhUmuXH5oN7lTpz7c1s2RGfnj5A+2nK+BPevbZDyILr/bRQp +Sxg7J7XL3LuvhBnx/bLxvLuaRSmp73KIJDZwEM4EUsXuueVw4bsoTQUsogL1sU9v +ahZjCL92Vc5iEfll9hrAJcMTERKjG09KU9DWfWeaMst/5V5UxoEhq4ZmhSI6h7uj +cy4vvQKouqYYyZ8R598lprp7FcraBcVZSp2qBvTtQQpEG+y5SBm3E/n5DtcNHZId +L6hHAmQciIdDv+oxbEp1rigCejWgcRfxWkNcVbR5TeucLZOUmNyWXmrCEd5Iz0vd +SeO2NMCUreP18PSuwEzXJ83rytj0bCMCAwEAAaNTMFEwHQYDVR0OBBYEFKD9ou8w +qANyOhdhBamcysOWrWiSMB8GA1UdIwQYMBaAFKD9ou8wqANyOhdhBamcysOWrWiS +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAAXXcTDfW6GVz/SH +UvjUvTIYbZ7Kl9Fcca8nY0m89bq8dt+/3ElxSrFzVojF2NgiQGdpY5/cI3x8XXLC +neKtfv/YElF+ikC11UMOdRThLEmP/MGG96i5bv5O085Nbi7/e3Jy+kALgc5rOgvX +JyhvinYpiIu5OkdZqQuNBKCELosxBMTRY5d32ABEYRz/Vf2s5v2jzU5zxo3+9rbb +NYGu0rkd1/2/Rkxzt32Xs4mUsgZjttqx4Wmvo0k3WP2ZwH4te79JUZGO7JvsiDjo +Cx8f+cvhCdMa+I4l7+BrcBNyUNQ+S6WDKTzRObXZPUQ/XmMCp1wecG6yxMlMzvFk +fzY2PbZC5u6GmvETG6lk0Xwq3hMljcko+A8F9P3wzLUlSwwLJG5OY7VuQtMDfYBW +48aGns339ROJlt+bv5THhzZBSpgYZarfRTYG/uVzs+Sk4jutpPI76doYCBMQ6CEm +X7OdTWrWdbQGBIyVOZJzhAOFD2YUE9eUrOgo1vNVUjMIPVVXdqhBn0h5alho58Xc +cdhLJlps9UEzRm9RDomhET9WFVtWuh4WVL5IZ6IOo4ghPcvWDKtb8Py/xeBl5eJQ +izAl7kqk3+PgY5bRriSS90o5qfJLQlCOka9sxvwSg/ovCmeUGla2R6xunH368fmG +1TGq80KKLslZgDyfoxzi1wosrFkN +-----END CERTIFICATE----- diff --git a/pdf/apk/src/com/android/graphics/pdf/Placeholder.java b/pdf/apk/src/com/android/graphics/pdf/Placeholder.java new file mode 100644 index 000000000..7de45b342 --- /dev/null +++ b/pdf/apk/src/com/android/graphics/pdf/Placeholder.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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.graphics.pdf; + +/** + * Placeholder class for new PDF viewer apk inside MediaProvider module. + * + * @hide + * + */ +public class Placeholder { +} diff --git a/apex/pdf/framework/Android.bp b/pdf/framework/Android.bp index 99aa40461..99aa40461 100644 --- a/apex/pdf/framework/Android.bp +++ b/pdf/framework/Android.bp diff --git a/apex/pdf/framework/api/current.txt b/pdf/framework/api/current.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/current.txt +++ b/pdf/framework/api/current.txt diff --git a/apex/pdf/framework/api/module-lib-current.txt b/pdf/framework/api/module-lib-current.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/module-lib-current.txt +++ b/pdf/framework/api/module-lib-current.txt diff --git a/apex/pdf/framework/api/module-lib-removed.txt b/pdf/framework/api/module-lib-removed.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/module-lib-removed.txt +++ b/pdf/framework/api/module-lib-removed.txt diff --git a/apex/pdf/framework/api/removed.txt b/pdf/framework/api/removed.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/removed.txt +++ b/pdf/framework/api/removed.txt diff --git a/apex/pdf/framework/api/system-current.txt b/pdf/framework/api/system-current.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/system-current.txt +++ b/pdf/framework/api/system-current.txt diff --git a/apex/pdf/framework/api/system-removed.txt b/pdf/framework/api/system-removed.txt index d802177e2..d802177e2 100644 --- a/apex/pdf/framework/api/system-removed.txt +++ b/pdf/framework/api/system-removed.txt diff --git a/apex/pdf/framework/java/android/graphics/pdf/Placeholder.java b/pdf/framework/java/android/graphics/pdf/Placeholder.java index 4560b286e..4560b286e 100644 --- a/apex/pdf/framework/java/android/graphics/pdf/Placeholder.java +++ b/pdf/framework/java/android/graphics/pdf/Placeholder.java diff --git a/res/drawable/error_icon.xml b/res/drawable/error_icon.xml new file mode 100644 index 000000000..e9433b758 --- /dev/null +++ b/res/drawable/error_icon.xml @@ -0,0 +1,17 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <group> + <clip-path + android:pathData="M0,0h24v24h-24z"/> + <path + android:pathData="M1,21L12,2L23,21H1ZM4.45,19H19.55L12,6L4.45,19ZM12,18C12.283,18 + 12.517,17.908 12.7,17.725C12.9,17.525 13,17.283 13,17C13,16.717 12.9,16.483 + 12.7,16.3C12.517,16.1 12.283,16 12,16C11.717,16 11.475,16.1 11.275,16.3C11.092,16.483 + 11,16.717 11,17C11,17.283 11.092,17.525 11.275,17.725C11.475,17.908 11.717,18 12, + 18ZM11,15H13V10H11V15Z" + android:fillColor="#775A0B"/> + </group> +</vector> diff --git a/res/drawable/ic_artwork_camera.xml b/res/drawable/ic_artwork_camera.xml index dc22c492d..9c39e648b 100644 --- a/res/drawable/ic_artwork_camera.xml +++ b/res/drawable/ic_artwork_camera.xml @@ -14,9 +14,11 @@ limitations under the License. --> +<!-- This vector draws the camera graphic that is displayed in the picker when the + device has no images/videos i.e. the picker is empty --> <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="120dp" - android:height="80dp" + android:width="100dp" + android:height="66.67dp" android:viewportWidth="120" android:viewportHeight="80"> <path diff --git a/res/drawable/ic_background_circle.xml b/res/drawable/ic_background_circle.xml new file mode 100644 index 000000000..ec6f524fc --- /dev/null +++ b/res/drawable/ic_background_circle.xml @@ -0,0 +1,20 @@ +<!-- + ~ Copyright (C) 2023 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. + --> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="?attr/categoryDefaultThumbnailCircleColor" /> +</shape>
\ No newline at end of file diff --git a/res/drawable/picker_app_icon.xml b/res/drawable/picker_app_icon.xml new file mode 100644 index 000000000..9f8334423 --- /dev/null +++ b/res/drawable/picker_app_icon.xml @@ -0,0 +1,26 @@ +<!-- + ~ Copyright (C) 2023 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:pathData="M8,2H20C21.1,2 22,2.9 22,4V16C22,17.1 21.1,18 20,18H8C6.9,18 6,17.1 6,16V4C6,2.9 6.9,2 8,2ZM20,16V4H8V16H20ZM2,6V20C2,21.1 2.9,22 4,22H18V20H4V6H2ZM13.17,13.98L15.67,11L19,15H9L11.5,11.8L13.17,13.98Z" + android:fillColor="#5F6368" + android:fillType="evenOdd"/> +</vector> diff --git a/res/drawable/picker_item_check.xml b/res/drawable/picker_item_check.xml index fb0ef887a..c73c699c8 100644 --- a/res/drawable/picker_item_check.xml +++ b/res/drawable/picker_item_check.xml @@ -1,31 +1,31 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2021 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. - You may obtain a copy of the License at + 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 + 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. ---> + 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> +<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>
\ No newline at end of file diff --git a/res/drawable/picker_item_order.xml b/res/drawable/picker_item_order.xml new file mode 100644 index 000000000..ceeae40b0 --- /dev/null +++ b/res/drawable/picker_item_order.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2023 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="?attr/pickerSelectedColor"/> + </shape> + </item> + </layer-list> + </item> + <item android:drawable="@drawable/ic_radio_button_unchecked"/> +</selector>
\ No newline at end of file diff --git a/res/drawable/thumbnail_favorites.xml b/res/drawable/thumbnail_favorites.xml new file mode 100644 index 000000000..a1a8101fe --- /dev/null +++ b/res/drawable/thumbnail_favorites.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (C) 2023 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:pathData="M14.81,8.62L22,9.24L16.55,13.97L18.18,21L12,17.27L5.82,21L7.46,13.97L2,9.24L9.19,8.63L12,2L14.81,8.62ZM8.24,17.67L12,15.4L15.77,17.68L14.77,13.4L18.09,10.52L13.71,10.14L12,6.1L10.3,10.13L5.92,10.51L9.24,13.39L8.24,17.67Z" + android:fillColor="#5B631D" + android:fillType="evenOdd"/> +</vector> diff --git a/res/drawable/thumbnail_videos.xml b/res/drawable/thumbnail_videos.xml new file mode 100644 index 000000000..e5944d5c7 --- /dev/null +++ b/res/drawable/thumbnail_videos.xml @@ -0,0 +1,26 @@ +<!-- + ~ Copyright (C) 2023 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:pathData="M18,6V10.48L22,6.5V17.5L18,13.52V18C18,19.1 17.1,20 16,20H4C2.9,20 2,19.1 2,18V6C2,4.9 2.9,4 4,4H16C17.1,4 18,4.9 18,6ZM16,6H4V18H16V6Z" + android:fillColor="#5B631D" + android:fillType="evenOdd"/> +</vector> + diff --git a/res/layout/error_dialog.xml b/res/layout/error_dialog.xml new file mode 100644 index 000000000..5fa23d171 --- /dev/null +++ b/res/layout/error_dialog.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="20dp" + android:gravity="center"> + <ImageView + android:layout_width="34dp" + android:layout_height="34dp" + android:src="@drawable/error_icon" + android:layout_gravity="center_horizontal" + android:layout_marginBottom="16dp" + android:importantForAccessibility="no"/> + + <TextView + android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/dialog_error_title" + android:textSize="24sp" + android:layout_gravity="center_horizontal" + android:gravity="center" + android:textColor="?android:attr/textColorPrimary"/> + + <TextView + android:id="@+id/message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/dialog_error_message" + android:layout_marginTop="16dp" + android:layout_gravity="center_horizontal" + android:gravity="center" + android:textSize="16sp" + android:textColor="?android:attr/textColorSecondary"/> + + <Button + android:id="@+id/okButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/dialog_button_text" + android:layout_marginTop="16dp" + android:layout_gravity="end" + android:textColor="?attr/pickerHighlightTextColor" + android:backgroundTint="?attr/pickerHighlightColor"/> +</LinearLayout>
\ No newline at end of file diff --git a/res/layout/fragment_picker_tab.xml b/res/layout/fragment_picker_tab.xml index ae3180d4a..7dd6ea987 100644 --- a/res/layout/fragment_picker_tab.xml +++ b/res/layout/fragment_picker_tab.xml @@ -20,34 +20,44 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - <LinearLayout + <!-- The nested scroll view holds the layout that is made visible when + the picker is empty. It has been wrapped in the scroll view to tackle + bugs where the "empty_text_view" gets rolled off the screen partially + or completely in small screen devices --> + <androidx.core.widget.NestedScrollView android:id="@android:id/empty" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="80dp" - android:orientation="vertical" android:visibility="gone"> - <ImageView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="center_horizontal" - android:scaleType="fitCenter" - android:src="@drawable/ic_artwork_camera" - android:contentDescription="@null"/> - - <TextView - android:id="@+id/empty_text_view" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/picker_empty_text_margin" - android:gravity="center_horizontal" - android:text="@string/picker_photos_empty_message" - android:textColor="?android:attr/textColorSecondary" - android:textSize="@dimen/picker_empty_text_size" - style="?android:attr/textAppearanceListItem"/> + android:orientation="vertical"> + + <ImageView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:scaleType="fitCenter" + android:src="@drawable/ic_artwork_camera" + android:contentDescription="@null"/> - </LinearLayout> + <TextView + android:id="@+id/empty_text_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/picker_empty_text_margin" + android:gravity="center_horizontal" + android:text="@string/picker_photos_empty_message" + android:textColor="?android:attr/textColorSecondary" + android:textSize="@dimen/picker_empty_text_size" + style="?android:attr/textAppearanceListItem"/> + + </LinearLayout> + + </androidx.core.widget.NestedScrollView> <com.android.providers.media.photopicker.ui.AutoFitRecyclerView android:id="@+id/picker_tab_recyclerview" @@ -57,4 +67,24 @@ android:drawSelectorOnTop="true" android:overScrollMode="never"/> + <TextView + android:id="@+id/loading_text_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:text="@string/picker_loading_photos_message" + android:textColor="?android:attr/textColorPrimary" + android:textSize="@dimen/picker_tab_loading_message_text_size" + style="?android:attr/textAppearanceListItem" + android:visibility="gone"/> + + <ProgressBar + android:id="@+id/progress_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/picker_progress_bar_margin_top" + style="@style/android:Widget.Material.ProgressBar.Horizontal" + android:indeterminate="true" + android:visibility="gone"/> + </FrameLayout> diff --git a/res/layout/item_album_grid.xml b/res/layout/item_album_grid.xml index 4c04fff3c..c09beaf0e 100644 --- a/res/layout/item_album_grid.xml +++ b/res/layout/item_album_grid.xml @@ -38,6 +38,16 @@ android:scaleType="centerCrop" android:contentDescription="@null"/> + <ImageView + android:id="@+id/icon_default_thumbnail" + android:layout_width="56dp" + android:layout_height="56dp" + android:scaleType="centerCrop" + android:tint="?attr/categoryDefaultThumbnailColor" + android:contentDescription="@null" + android:layout_gravity="center" + android:background="@drawable/ic_background_circle" + android:padding="16dp"/> </com.google.android.material.card.MaterialCardView> <TextView @@ -57,6 +67,7 @@ android:minHeight="@dimen/picker_album_item_count_height" android:layout_marginTop="@dimen/picker_album_item_count_margin" android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small" - android:textColor="?android:attr/textColorSecondary"/> + android:textColor="?android:attr/textColorSecondary" + android:visibility="gone"/> </LinearLayout> diff --git a/res/layout/item_photo_grid.xml b/res/layout/item_photo_grid.xml index f28305c6b..cd19343ca 100644 --- a/res/layout/item_photo_grid.xml +++ b/res/layout/item_photo_grid.xml @@ -108,4 +108,17 @@ android:layout_gravity="top|start" android:scaleType="fitCenter"/> + <TextView + android:id="@+id/selected_order" + 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:background="@drawable/picker_item_order" + android:layout_gravity="top|start" + android:gravity="center" + android:textSize="12dp" + android:textColor="?attr/pickerHighlightTextColor" + android:scaleType="fitCenter"/> + </FrameLayout> diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml index 804d6123e..e3d1c1e6a 100644 --- a/res/values-af/strings.xml +++ b/res/values-af/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Plaaslike berging"</string> - <string name="app_label" msgid="9035307001052716210">"Mediaberging"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Mediakieser"</string> <string name="artist_label" msgid="8105600993099120273">"Kunstenaar"</string> <string name="unknown" msgid="2059049215682829375">"Onbekend"</string> <string name="root_images" msgid="5861633549189045666">"Prente"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Kry toegang tot wolkmedia vanaf"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Geen"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Kon nie wolkmedia-app op dié tydstip verander nie."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Mediakieser"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Mediakieser"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sinkroniseer tans media …"</string> <string name="add" msgid="2894574044585549298">"Voeg by"</string> <string name="deselect" msgid="4297825044827769490">"Ontkies"</string> <string name="deselected" msgid="8488133193326208475">"Ontkies"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Geen albums nie"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Bekyk geselekteerde"</string> <string name="picker_photos" msgid="7415035516411087392">"Foto\'s"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Voorskou"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Skakel oor na werk"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Voeg by (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Laat toe (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Laat geen toe nie"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Aflaaie"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Gunstelinge"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Sukkel om video te speel"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Gaan jou internetverbinding na en probeer weer"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Herprobeer"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Wolkmedia is nou deur <xliff:g id="PKG_NAME">%1$s</xliff:g> beskikbaar"</string> <string name="not_selected" msgid="2244008151669896758">"nie gekies nie"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Berei tans jou geselekteerde media voor"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> van <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> is gereed"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Kanselleer"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Gerugsteunde foto\'s word nou ingesluit"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Jy kan foto\'s van <xliff:g id="APP_NAME">%1$s</xliff:g>-rekening <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> af kies"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-rekening is opgedateer"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Kies app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Kies rekening"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Verander rekening"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Kry tans al jou foto’s"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Laat <xliff:g id="APP_NAME_0">^1</xliff:g> toe om hierdie oudiolêer te wysig?}other{Laat <xliff:g id="APP_NAME_1">^1</xliff:g> toe om <xliff:g id="COUNT">^2</xliff:g> oudiolêers te wysig?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Wysig tans oudiolêer …}other{Wysig tans <xliff:g id="COUNT">^1</xliff:g> oudiolêers …}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Laat <xliff:g id="APP_NAME_0">^1</xliff:g> toe om hierdie video te wysig?}other{Laat <xliff:g id="APP_NAME_1">^1</xliff:g> toe om <xliff:g id="COUNT">^2</xliff:g> video\'s te wysig?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Veiligheidbeskerming"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Toestelspesifieke kodewisselingopletberigte"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Toestelspesifieke kodewisselingvordering"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Probeer later weer. Jou foto’s sal beskikbaar wees sodra die kwessie opgelos is."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Sommige foto’s kan nie laai nie"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Het dit"</string> </resources> diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml index ed4ad7349..32b2d972c 100644 --- a/res/values-am/strings.xml +++ b/res/values-am/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"ማህደረመረጃ"</string> <string name="storage_description" msgid="4081716890357580107">"አካባቢያዊ ማከማቻ"</string> - <string name="app_label" msgid="9035307001052716210">"ማህደረ መረጃ ማከማቻ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"ሚዲያ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"የሚዲያ መራጭ"</string> <string name="artist_label" msgid="8105600993099120273">"አርቲስት"</string> <string name="unknown" msgid="2059049215682829375">"የማይታወቅ"</string> <string name="root_images" msgid="5861633549189045666">"ምስሎች"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"የደመና ሚዲያን ይድረሱ ከ"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ምንም"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"በዚህ ጊዜ የደመና የሚዲያ መተግበሪያን መለወጥ አልተቻለም።"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"የሚዲያ መራጭ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"የሚዲያ መራጭ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"ሚዲያ በማስመር ላይ…"</string> <string name="add" msgid="2894574044585549298">"አክል"</string> <string name="deselect" msgid="4297825044827769490">"አትምረጥ"</string> <string name="deselected" msgid="8488133193326208475">"አልተመረጠም"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ምንም አልበሞች የሉም"</string> <string name="picker_view_selected" msgid="2266031384396143883">"የተመረጡትን አሳይ"</string> <string name="picker_photos" msgid="7415035516411087392">"ፎቶዎች"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"አልበሞች"</string> <string name="picker_preview" msgid="6257414886055861039">"ቅድመ-ዕይታ"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ወደ የሥራ ቀይር"</string> @@ -72,10 +76,11 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ንጥል}one{<xliff:g id="COUNT_1">^1</xliff:g> ንጥል}other{<xliff:g id="COUNT_1">^1</xliff:g> ንጥሎች}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) አክል"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"ለ(<xliff:g id="COUNT">^1</xliff:g>) ፍቀድ"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ምንም አትፍቀድ"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ካሜራ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ውርዶች"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ተወዳጆች"</string> - <string name="picker_category_screenshots" msgid="7216102327587644284">"ቅጽበታዊ ገጽ እይታዎች"</string> + <string name="picker_category_screenshots" msgid="7216102327587644284">"ቅጽበታዊ ገፅ እይታዎች"</string> <!-- no translation found for picker_category_videos (1478458836380241356) --> <skip /> <string name="picker_motion_photo_text" msgid="5016603812468180816">"የእንቅስቃሴ ፎቶ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ቪድዮን ማጫወት ላይ ችግር"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"የበይነመረብዎን ግንኙነት ይፈትሹ እና እንደገና ይሞክሩ"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"እንደገና ሞክር"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"የደመና ሚዲያ አሁን ከ<xliff:g id="PKG_NAME">%1$s</xliff:g> ይገኛል"</string> <string name="not_selected" msgid="2244008151669896758">"አልተመረጠም"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"የእርስዎን የተመረጠ ሚዲያ በማዘጋጀት ላይ"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> ከ<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ዝግጁ"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ይቅር"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ምትኬ የተቀመጠላቸው ፎቶዎች አሁን ተካትተዋል"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ከ<xliff:g id="APP_NAME">%1$s</xliff:g> መለያ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ፎቶዎችን መምረጥ ይችላሉ"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> መለያ ተዘምኗል"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"መተግበሪያ ይምረጡ"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"መለያ ይምረጡ"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"መለያ ቀይር"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ሁሉንም ፎቶዎችዎን በማምጣት ላይ"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ይህን ኦዲዮ ፋይል እንዲቀይር ይፈቀድለት?}one{<xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ኦዲዮ ፋይልን እንዲቀይር ይፈቀድለት?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ኦዲዮ ፋይሎችን እንዲቀይር ይፈቀድለት?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{የኦዲዮ ፋይልን በመቀየር ላይ…}one{<xliff:g id="COUNT">^1</xliff:g> የኦዲዮ ፋይልን በመቀየር ላይ…}other{<xliff:g id="COUNT">^1</xliff:g> የኦዲዮ ፋይሎችን በመቀየር ላይ…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ይህን ቪዲዮ እንዲቀይር ይፈቀድለት?}one{<xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ቪዲዮን እንዲቀይር ይፈቀድለት?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> ቪዲዮዎችን እንዲቀይር ይፈቀድለት?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"የደህንነት ጥበቃ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"የቤተኛ ትራንስኮድ ማንቂያዎች"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"የቤተኛ ትራንስኮድ ሂደት"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ቆይተው እንደገና ይሞክሩ። የእርስዎ ፎቶዎች አንዴ ችግሩ ከተፈታ በኋላ ይገኛሉ።"</string> + <string name="dialog_error_title" msgid="636349284077820636">"አንዳንድ ፎቶዎችን መጫን አይቻለም"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ገባኝ"</string> </resources> diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml index 8bd02ca25..9ff9503f3 100644 --- a/res/values-ar/strings.xml +++ b/res/values-ar/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"الوسائط"</string> <string name="storage_description" msgid="4081716890357580107">"التخزين المحلي"</string> - <string name="app_label" msgid="9035307001052716210">"تخزين الوسائط"</string> - <string name="picker_app_label" msgid="4254039089502164761">"الوسائط"</string> + <string name="picker_app_label" msgid="1195424381053599122">"أداة اختيار الوسائط"</string> <string name="artist_label" msgid="8105600993099120273">"الفنان"</string> <string name="unknown" msgid="2059049215682829375">"غير معروف"</string> <string name="root_images" msgid="5861633549189045666">"الصور"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"الوصول إلى الوسائط في السحابة الإلكترونية من"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"بلا تطبيق"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"تعذر تغيير تطبيق وسائط في السحابة الإلكترونية حاليًا"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"أداة اختيار الوسائط"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"أداة اختيار الوسائط"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"جارٍ مزامنة الوسائط…"</string> <string name="add" msgid="2894574044585549298">"إضافة"</string> <string name="deselect" msgid="4297825044827769490">"إلغاء الاختيار"</string> <string name="deselected" msgid="8488133193326208475">"تم إلغاء الاختيار"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ما مِن ألبومات"</string> <string name="picker_view_selected" msgid="2266031384396143883">"عرض ما تم اختياره"</string> <string name="picker_photos" msgid="7415035516411087392">"الصور"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"الألبومات"</string> <string name="picker_preview" msgid="6257414886055861039">"معاينة"</string> <string name="picker_work_profile" msgid="2083221066869141576">"التبديل إلى الملف الشخصي للعمل"</string> @@ -69,9 +73,10 @@ <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"لفتح صور العمل، عليك تفعيل تطبيقات العمل ثم إعادة المحاولة."</string> <string name="picker_privacy_message" msgid="9132700451027116817">"يمكن لهذا التطبيق الوصول إلى الصور التي تختارها فقط."</string> <string name="picker_header_permissions" msgid="675872774407768495">"اختَر الصور والفيديوهات التي تريد السماح لهذا التطبيق بالوصول إليها"</string> - <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{عنصر واحد (<xliff:g id="COUNT_0">^1</xliff:g>)}zero{<xliff:g id="COUNT_1">^1</xliff:g> عنصر}two{عنصران (<xliff:g id="COUNT_1">^1</xliff:g>)}few{<xliff:g id="COUNT_1">^1</xliff:g> عناصر}many{<xliff:g id="COUNT_1">^1</xliff:g> عنصرًا}other{<xliff:g id="COUNT_1">^1</xliff:g> عنصر}}"</string> + <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{صورة واحدة (<xliff:g id="COUNT_0">^1</xliff:g>)}zero{<xliff:g id="COUNT_1">^1</xliff:g> صورة}two{صورتان (<xliff:g id="COUNT_1">^1</xliff:g>)}few{<xliff:g id="COUNT_1">^1</xliff:g> صور}many{<xliff:g id="COUNT_1">^1</xliff:g> صورة}other{<xliff:g id="COUNT_1">^1</xliff:g> صورة}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"إضافة (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"السماح (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"لم يتم اختيار أي صورة"</string> <string name="picker_category_camera" msgid="4857367052026843664">"الكاميرا"</string> <string name="picker_category_downloads" msgid="793866660287361900">"العناصر التي تم تنزيلها"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"العناصر المفضّلة"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"مشكلة في تشغيل الفيديو"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"يُرجى التحقّق من الاتصال بالإنترنت ثم إعادة المحاولة."</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"إعادة المحاولة"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"يتوفّر محتوى الوسائط على السحابة الإلكترونية الآن من خلال تطبيق <xliff:g id="PKG_NAME">%1$s</xliff:g>."</string> <string name="not_selected" msgid="2244008151669896758">"غير محدّد"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"جارٍ تحضير الوسائط التي تم اختيارها"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> من إجمالي <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> صورة جاهزة"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"إلغاء"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"تم الآن تضمين الصور التي تم الاحتفاظ بنسخة احتياطية منها"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"يمكنك اختيار صور من حساب <xliff:g id="APP_NAME">%1$s</xliff:g> للمستخدم <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>."</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"يمكنك اختيار صور من حساب \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" للمستخدم <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>."</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"تم تعديل الحساب <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"تم الآن تضمين الصور من <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> هنا."</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"اختيار تطبيق موسيقى على السحابة الإلكترونية"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"اختيار تطبيق"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"اختيار حساب"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"تبديل الحساب"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"جارٍ تحميل جميع الصور"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_0">^1</xliff:g> بتعديل هذا الملف الصوتي؟}zero{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> ملف صوتي؟}two{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل ملفَين صوتيين (<xliff:g id="COUNT">^2</xliff:g>)؟}few{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> ملفات صوتية؟}many{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> ملفًا صوتيًا؟}other{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> ملف صوتي؟}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{جارٍ تعديل ملف صوتي واحد…}zero{جارٍ تعديل <xliff:g id="COUNT">^1</xliff:g> ملف صوتي…}two{جارٍ تعديل ملفَين صوتين (<xliff:g id="COUNT">^1</xliff:g>)…}few{جارٍ تعديل <xliff:g id="COUNT">^1</xliff:g> ملفات صوتية…}many{جارٍ تعديل <xliff:g id="COUNT">^1</xliff:g> ملفًا صوتيًا…}other{جارٍ تعديل <xliff:g id="COUNT">^1</xliff:g> ملف صوتي…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_0">^1</xliff:g> بتعديل هذا الفيديو؟}zero{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> فيديو؟}two{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل فيديوهين (<xliff:g id="COUNT">^2</xliff:g>)؟}few{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> فيديوهات؟}many{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> فيديو؟}other{هل تريد السماح لتطبيق <xliff:g id="APP_NAME_1">^1</xliff:g> بتعديل <xliff:g id="COUNT">^2</xliff:g> فيديو؟}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"حماية الأمن الشخصي"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"تنبيهات Native Transcode"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"مدى تقدُّم Native Transcode"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"يُرجى إعادة المحاولة لاحقًا. ستتوفّر صورك عند حل المشكلة."</string> + <string name="dialog_error_title" msgid="636349284077820636">"يتعذّر تحميل بعض الصور"</string> + <string name="dialog_button_text" msgid="351366485240852280">"حسنًا"</string> </resources> diff --git a/res/values-as/strings.xml b/res/values-as/strings.xml index d0bbafd63..f97b5c1f9 100644 --- a/res/values-as/strings.xml +++ b/res/values-as/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"মিডিয়া"</string> <string name="storage_description" msgid="4081716890357580107">"স্থানীয় ষ্ট’ৰেজ"</string> - <string name="app_label" msgid="9035307001052716210">"মিডিয়া ষ্ট’ৰেজ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"মিডিয়া"</string> + <string name="picker_app_label" msgid="1195424381053599122">"মিডিয়া বাছনিকৰ্তা"</string> <string name="artist_label" msgid="8105600993099120273">"শিল্পী"</string> <string name="unknown" msgid="2059049215682829375">"অজ্ঞাত"</string> <string name="root_images" msgid="5861633549189045666">"প্ৰতিচ্ছবি"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ইয়াৰ পৰা ক্লাউড মিডিয়া এক্সেছ কৰক"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"নাই"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"এই সময়ত ক্লাউড মিডিয়া এপ্ সলনি কৰিব নোৱাৰি"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"মিডিয়া বাছনিকৰ্তা"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"মিডিয়া বাছনিকৰ্তা"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"মিডিয়া ছিংক কৰি থকা হৈছে…"</string> <string name="add" msgid="2894574044585549298">"যোগ দিয়ক"</string> <string name="deselect" msgid="4297825044827769490">"বাছনিৰ পৰা আঁতৰাওক"</string> <string name="deselected" msgid="8488133193326208475">"বাছনিৰ পৰা আঁতৰোৱা হ’ল"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"কোনো এলবাম নাই"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ভিউ বাছনি কৰা হৈছে"</string> <string name="picker_photos" msgid="7415035516411087392">"ফট’"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"এলবাম"</string> <string name="picker_preview" msgid="6257414886055861039">"পূৰ্বদৰ্শন কৰক"</string> <string name="picker_work_profile" msgid="2083221066869141576">"কৰ্মস্থানৰ প্ৰ’ফাইললৈ সলনি কৰক"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> টা বস্তু}one{<xliff:g id="COUNT_1">^1</xliff:g> টা বস্তু}other{<xliff:g id="COUNT_1">^1</xliff:g> টা বস্তু}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g> টা) যোগ দিয়ক"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"অনুমতি দিয়ক (<xliff:g id="COUNT">^1</xliff:g> টা)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"এখনৰো অনুমতি নিদিব"</string> <string name="picker_category_camera" msgid="4857367052026843664">"কেমেৰা"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ডাউনল’ড"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"প্ৰিয়"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ভিডিঅ’ প্লে’ কৰাত সমস্যা হৈছে"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"আপোনাৰ ইণ্টাৰনেট সংযোগ পৰীক্ষা কৰক আৰু পুনৰ চেষ্টা কৰক"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"পুনৰ চেষ্টা কৰক"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"এতিয়া <xliff:g id="PKG_NAME">%1$s</xliff:g>ৰ পৰা ক্লাউড মিডিয়া উপলব্ধ"</string> <string name="not_selected" msgid="2244008151669896758">"বাছনি কৰা হোৱা নাই"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"আপুনি বাছনি কৰা মিডিয়া সাজু কৰি থকা হৈছে"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> টা বস্তুৰ ভিতৰত <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> টা সাজু"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"বাতিল কৰক"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"এতিয়া বেকআপ লোৱা ফট’সমূহ অন্তৰ্ভুক্ত কৰা হৈছে"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"আপুনি <xliff:g id="APP_NAME">%1$s</xliff:g>ৰ একাউণ্টৰ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> পৰা ফট’সমূহ বাছনি কৰিব পাৰে"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> একাউণ্টটো আপডে’ট কৰা হৈছে"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"সুৰক্ষিত নিৰাপত্তা"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"স্থানীয় ট্ৰেন্সক’ড সতৰ্কবাৰ্তা"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"স্থানীয় ট্ৰেন্সক’ড অগ্ৰগতি"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"পাছত পুনৰ চেষ্টা কৰক। সমস্যাটো সমাধান হোৱাৰ পাছত আপোনাৰ ফট’সমূহ উপলব্ধ হ’ব।"</string> + <string name="dialog_error_title" msgid="636349284077820636">"কিছুমান ফট’ ল’ড কৰিব নোৱাৰি"</string> + <string name="dialog_button_text" msgid="351366485240852280">"বুজি পালোঁ"</string> </resources> diff --git a/res/values-az/strings.xml b/res/values-az/strings.xml index e1eec60e6..88c4c05b8 100644 --- a/res/values-az/strings.xml +++ b/res/values-az/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Yerli yaddaş"</string> - <string name="app_label" msgid="9035307001052716210">"Media Yaddaşı"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media seçici"</string> <string name="artist_label" msgid="8105600993099120273">"Sənətçi"</string> <string name="unknown" msgid="2059049215682829375">"Naməlum"</string> <string name="root_images" msgid="5861633549189045666">"Təsvirlər"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Bulud mediasına buradan giriş edin:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Heç biri"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"İndi bulud media tətbiqini dəyişmək mümkün deyil."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media seçici"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media seçici"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Media sinxronlaşdırılır…"</string> <string name="add" msgid="2894574044585549298">"Əlavə edin"</string> <string name="deselect" msgid="4297825044827769490">"Seçimi ləğv edin"</string> <string name="deselected" msgid="8488133193326208475">"Seçimi ləğv edilib"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Albom yoxdur"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Seçilənə baxın"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotolar"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albomlar"</string> <string name="picker_preview" msgid="6257414886055861039">"Önbaxış"</string> <string name="picker_work_profile" msgid="2083221066869141576">"İş profilinə keçirin"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}other{<xliff:g id="COUNT_1">^1</xliff:g> element}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Əlavə edin (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"İcazə verin (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Heç birinə icazə verməyin"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Endirmələr"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Sevimlilər"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Videonu oxudarkən xəta oldu"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"İnternet bağlantınızı yoxlayın və yenidən sınayın"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Yenidən cəhd edin"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Bulud mediası indi buradan əlçatandır: <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"seçilməyib"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Seçilmiş media hazırlanır"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>/<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> hazırdır"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Ləğv edin"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Yedəklənmiş fotolar indi daxildir"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> hesabından (<xliff:g id="USER_ACCOUNT">%2$s</xliff:g>) fotoları seçə bilərsiniz"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> hesabı güncəlləndi"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Tətbiq seçin"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Hesab seçin"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Hesabı dəyişdirin"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Bütün fotolar əldə edilir"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> tətbiqinə bu audio fayla dəyişiklik etmək icazəsi verilsin?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Audio fayl dəyişdirilir…}other{<xliff:g id="COUNT">^1</xliff:g> audio fayl dəyişdirilir…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> tətbiqinə bu videoya dəyişiklik etmək icazəsi verilsin?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> tətbiqinə <xliff:g id="COUNT">^2</xliff:g> videoya dəyişiklik etmək icazəsi verilsin?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Güvənlik qoruması"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Orijinal Transkod Xəbərdarlıqları"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Orijinal Transkod İrəliləyişi"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Sonra cəhd edin. Problem həll edildikdən sonra fotolar əlçatan olacaq."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Bəzi fotolar yüklənmir"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Anladım"</string> </resources> diff --git a/res/values-b+sr+Latn/strings.xml b/res/values-b+sr+Latn/strings.xml index d83a56219..0c40717d7 100644 --- a/res/values-b+sr+Latn/strings.xml +++ b/res/values-b+sr+Latn/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Mediji"</string> <string name="storage_description" msgid="4081716890357580107">"Lokalni memorijski prostor"</string> - <string name="app_label" msgid="9035307001052716210">"Memorijski prostor za medije"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Mediji"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Birač medija"</string> <string name="artist_label" msgid="8105600993099120273">"Izvođač"</string> <string name="unknown" msgid="2059049215682829375">"Nepoznato"</string> <string name="root_images" msgid="5861633549189045666">"Slike"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Pristupajte medijima u klaudu iz"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ništa"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Promena aplikacije za medije u klaudu nije uspela."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Birač medija"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Birač medija"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Mediji se sinhronizuju…"</string> <string name="add" msgid="2894574044585549298">"Dodaj"</string> <string name="deselect" msgid="4297825044827769490">"Opozovi izbor"</string> <string name="deselected" msgid="8488133193326208475">"Opozvan je izbor"</string> @@ -53,11 +55,13 @@ <string name="selected" msgid="9151797369975828124">"Izabrano"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Izaberite najviše <xliff:g id="COUNT_0">^1</xliff:g> stavku}one{Izaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavku}few{Izaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavke}other{Izaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavki}}"</string> <string name="recent" msgid="6694613584743207874">"Nedavno"</string> - <string name="picker_photos_empty_message" msgid="5980619500554575558">"Nema slika niti video snimaka"</string> - <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Nema podržanih slika niti video snimaka"</string> + <string name="picker_photos_empty_message" msgid="5980619500554575558">"Nema slika niti videa"</string> + <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Nema podržanih slika niti videa"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nema albuma"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži izabrano"</string> <string name="picker_photos" msgid="7415035516411087392">"Slike"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumi"</string> <string name="picker_preview" msgid="6257414886055861039">"Pregled"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Pređi na poslovni profil"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> stavka}one{<xliff:g id="COUNT_1">^1</xliff:g> stavka}few{<xliff:g id="COUNT_1">^1</xliff:g> stavke}other{<xliff:g id="COUNT_1">^1</xliff:g> stavki}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Dodaj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Dozvoli (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ne dozvoli nijednu"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Preuzeto"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Omiljeno"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Došlo je do greške pri puštanju videa"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Proverite internet vezu i probajte ponovo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Probaj ponovo"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"<xliff:g id="PKG_NAME">%1$s</xliff:g> sada nudi medijski sadržaj u klaudu"</string> <string name="not_selected" msgid="2244008151669896758">"nije izabrano"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Pripremaju se odabrani medijski fajlovi"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Spremno:<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> od <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Otkaži"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Sada su uvrštene rezervne kopije slika"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Možete da izaberete slike sa naloga <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> za <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Nalog za <xliff:g id="APP_NAME">%1$s</xliff:g> je ažuriran"</string> @@ -107,36 +113,35 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Odaberi aplikaciju"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Odaberi nalog"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Promeni nalog"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Preuzimaju se sve slike"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izmeni ovaj audio fajl?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> audio fajl?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> audio fajla?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> audio fajlova?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Menja se audio fajl…}one{Menja se <xliff:g id="COUNT">^1</xliff:g> audio fajl…}few{Menjaju se <xliff:g id="COUNT">^1</xliff:g> audio fajla…}other{Menja se <xliff:g id="COUNT">^1</xliff:g> audio fajlova…}}"</string> - <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izmeni ovaj video?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> video?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> video snimka?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> video snimaka?}}"</string> - <string name="permission_progress_write_video" msgid="7014908418349819148">"{count,plural, =1{Menja se video…}one{Menja se <xliff:g id="COUNT">^1</xliff:g> video…}few{Menjaju se <xliff:g id="COUNT">^1</xliff:g> video snimka…}other{Menja se <xliff:g id="COUNT">^1</xliff:g> video snimaka…}}"</string> + <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izmeni ovaj video?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> video?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> video snimka?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> videa?}}"</string> + <string name="permission_progress_write_video" msgid="7014908418349819148">"{count,plural, =1{Menja se video…}one{Menja se <xliff:g id="COUNT">^1</xliff:g> video…}few{Menjaju se <xliff:g id="COUNT">^1</xliff:g> video snimka…}other{Menja se <xliff:g id="COUNT">^1</xliff:g> videa…}}"</string> <string name="permission_write_image" msgid="3518991791620523786">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izmeni ovu sliku?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> sliku?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> slike?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> slika?}}"</string> <string name="permission_progress_write_image" msgid="3623580315590025262">"{count,plural, =1{Menja se slika…}one{Menja se <xliff:g id="COUNT">^1</xliff:g> slika…}few{Menjaju se <xliff:g id="COUNT">^1</xliff:g> slike…}other{Menja se <xliff:g id="COUNT">^1</xliff:g> slika…}}"</string> <string name="permission_write_generic" msgid="7431128739233656991">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izmeni ovu stavku?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> stavku?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> stavke?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izmeni <xliff:g id="COUNT">^2</xliff:g> stavki?}}"</string> <string name="permission_progress_write_generic" msgid="2806560971318391443">"{count,plural, =1{Menja se stavka…}one{Menja se <xliff:g id="COUNT">^1</xliff:g> stavka…}few{Menjaju se <xliff:g id="COUNT">^1</xliff:g> stavke…}other{Menja se <xliff:g id="COUNT">^1</xliff:g> stavki…}}"</string> <string name="permission_trash_audio" msgid="6554672354767742206">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj audio fajl u otpad?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajl u otpad?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajla u otpad?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajlova u otpad?}}"</string> <string name="permission_progress_trash_audio" msgid="3116279868733641329">"{count,plural, =1{Audio fajl se premešta u otpad…}one{<xliff:g id="COUNT">^1</xliff:g> audio fajl se premešta u otpad…}few{<xliff:g id="COUNT">^1</xliff:g> audio fajla se premeštaju u otpad…}other{<xliff:g id="COUNT">^1</xliff:g> audio fajlova se premešta u otpad…}}"</string> - <string name="permission_trash_video" msgid="7555850843259959642">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj video u otpad?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video u otpad?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimka u otpad?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimaka u otpad?}}"</string> - <string name="permission_progress_trash_video" msgid="4637821778329459681">"{count,plural, =1{Video se premešta u otpad…}one{<xliff:g id="COUNT">^1</xliff:g> video se premešta u otpad…}few{<xliff:g id="COUNT">^1</xliff:g> video snimka se premeštaju u otpad…}other{<xliff:g id="COUNT">^1</xliff:g> video snimaka se premešta u otpad…}}"</string> + <string name="permission_trash_video" msgid="7555850843259959642">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj video u otpad?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video u otpad?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimka u otpad?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> videa u otpad?}}"</string> + <string name="permission_progress_trash_video" msgid="4637821778329459681">"{count,plural, =1{Video se premešta u otpad…}one{<xliff:g id="COUNT">^1</xliff:g> video se premešta u otpad…}few{<xliff:g id="COUNT">^1</xliff:g> video snimka se premeštaju u otpad…}other{<xliff:g id="COUNT">^1</xliff:g> videa se premešta u otpad…}}"</string> <string name="permission_trash_image" msgid="3333128084684156675">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovu sliku u otpad?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> sliku u otpad?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> slike u otpad?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> slika u otpad?}}"</string> <string name="permission_progress_trash_image" msgid="3063857679090024764">"{count,plural, =1{Slika se premešta u otpad…}one{<xliff:g id="COUNT">^1</xliff:g> slika se premešta u otpad…}few{<xliff:g id="COUNT">^1</xliff:g> slike se premeštaju u otpad…}other{<xliff:g id="COUNT">^1</xliff:g> slika se premešta u otpad…}}"</string> <string name="permission_trash_generic" msgid="5545420534785075362">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovu stavku u otpad?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavku u otpad?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavke u otpad?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavki u otpad?}}"</string> <string name="permission_progress_trash_generic" msgid="7815124979717814057">"{count,plural, =1{Stavka se premešta u otpad…}one{<xliff:g id="COUNT">^1</xliff:g> stavka se premešta u otpad…}few{<xliff:g id="COUNT">^1</xliff:g> stavke se premeštaju u otpad…}other{<xliff:g id="COUNT">^1</xliff:g> stavki se premešta u otpad…}}"</string> <string name="permission_untrash_audio" msgid="8404597563284002472">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj audio fajl iz otpada?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajl iz otpada?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajla iz otpada?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> audio fajlova iz otpada?}}"</string> <string name="permission_progress_untrash_audio" msgid="2775372344946464508">"{count,plural, =1{Audio fajl se premešta iz otpada…}one{<xliff:g id="COUNT">^1</xliff:g> audio fajl se premešta iz otpada…}few{<xliff:g id="COUNT">^1</xliff:g> audio fajla se premeštaju iz otpada…}other{<xliff:g id="COUNT">^1</xliff:g> audio fajlova se premešta iz otpada…}}"</string> - <string name="permission_untrash_video" msgid="3178914827607608162">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj video iz otpada?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video iz otpada?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimka iz otpada?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimaka iz otpada?}}"</string> - <string name="permission_progress_untrash_video" msgid="5500929409733841567">"{count,plural, =1{Video se premešta iz otpada…}one{<xliff:g id="COUNT">^1</xliff:g> video se premešta iz otpada…}few{<xliff:g id="COUNT">^1</xliff:g> video snimka se premeštaju iz otpada…}other{<xliff:g id="COUNT">^1</xliff:g> video snimaka se premešta iz otpada…}}"</string> + <string name="permission_untrash_video" msgid="3178914827607608162">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovaj video iz otpada?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video iz otpada?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> video snimka iz otpada?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> videa iz otpada?}}"</string> + <string name="permission_progress_untrash_video" msgid="5500929409733841567">"{count,plural, =1{Video se premešta iz otpada…}one{<xliff:g id="COUNT">^1</xliff:g> video se premešta iz otpada…}few{<xliff:g id="COUNT">^1</xliff:g> video snimka se premeštaju iz otpada…}other{<xliff:g id="COUNT">^1</xliff:g> videa se premešta iz otpada…}}"</string> <string name="permission_untrash_image" msgid="3397523279351032265">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovu sliku iz otpada?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> sliku iz otpada?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> slike iz otpada?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> slika iz otpada?}}"</string> <string name="permission_progress_untrash_image" msgid="5295061520504846264">"{count,plural, =1{Slika se premešta iz otpada…}one{<xliff:g id="COUNT">^1</xliff:g> slika se premešta iz otpada…}few{<xliff:g id="COUNT">^1</xliff:g> slike se premeštaju iz otpada…}other{<xliff:g id="COUNT">^1</xliff:g> slika se premešta iz otpada…}}"</string> <string name="permission_untrash_generic" msgid="2118366929431671046">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> premesti ovu stavku iz otpada?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavku iz otpada?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavke iz otpada?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> premesti <xliff:g id="COUNT">^2</xliff:g> stavki iz otpada?}}"</string> <string name="permission_progress_untrash_generic" msgid="1489511601966842579">"{count,plural, =1{Stavka se premešta iz otpada…}one{<xliff:g id="COUNT">^1</xliff:g> stavka se premešta iz otpada…}few{<xliff:g id="COUNT">^1</xliff:g> stavke se premeštaju iz otpada…}other{<xliff:g id="COUNT">^1</xliff:g> stavki se premešta iz otpada…}}"</string> <string name="permission_delete_audio" msgid="3326674742892796627">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izbriše ovaj audio fajl?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> audio fajl?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> audio fajla?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> audio fajlova?}}"</string> <string name="permission_progress_delete_audio" msgid="1734871539021696401">"{count,plural, =1{Briše se audio fajl…}one{Briše se <xliff:g id="COUNT">^1</xliff:g> audio fajl…}few{Brišu se <xliff:g id="COUNT">^1</xliff:g> audio fajla…}other{Briše se <xliff:g id="COUNT">^1</xliff:g> audio fajlova…}}"</string> - <string name="permission_delete_video" msgid="604024971828349279">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izbriše ovaj video?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> video?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> video snimka?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> video snimaka?}}"</string> - <string name="permission_progress_delete_video" msgid="1846702435073793157">"{count,plural, =1{Briše se video…}one{Briše se <xliff:g id="COUNT">^1</xliff:g> video…}few{Brišu se <xliff:g id="COUNT">^1</xliff:g> video snimka…}other{Briše se <xliff:g id="COUNT">^1</xliff:g> video snimaka…}}"</string> + <string name="permission_delete_video" msgid="604024971828349279">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izbriše ovaj video?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> video?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> video snimka?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> videa?}}"</string> + <string name="permission_progress_delete_video" msgid="1846702435073793157">"{count,plural, =1{Briše se video…}one{Briše se <xliff:g id="COUNT">^1</xliff:g> video…}few{Brišu se <xliff:g id="COUNT">^1</xliff:g> video snimka…}other{Briše se <xliff:g id="COUNT">^1</xliff:g> videa…}}"</string> <string name="permission_delete_image" msgid="3109056012794330510">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izbriše ovu sliku?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> sliku?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> slike?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> slika?}}"</string> <string name="permission_progress_delete_image" msgid="8580517204901148906">"{count,plural, =1{Briše se slika…}one{Briše se <xliff:g id="COUNT">^1</xliff:g> slika…}few{Brišu se <xliff:g id="COUNT">^1</xliff:g> slike…}other{Briše se <xliff:g id="COUNT">^1</xliff:g> slika…}}"</string> <string name="permission_delete_generic" msgid="7891939881065520271">"{count,plural, =1{Želite li da dozvolite da <xliff:g id="APP_NAME_0">^1</xliff:g> izbriše ovu stavku?}one{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> stavku?}few{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> stavke?}other{Želite li da dozvolite da <xliff:g id="APP_NAME_1">^1</xliff:g> izbriše <xliff:g id="COUNT">^2</xliff:g> stavki?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Sigurnosna zaštita"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Obaveštenja o osnovnom transkodiranju"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Tok osnovnog transkodiranja"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Probajte ponovo kasnije. Slike će biti dostupne kada se problem reši."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Učitavanje nekih slika nije uspelo"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Važi"</string> </resources> diff --git a/res/values-be/strings.xml b/res/values-be/strings.xml index cb32adce8..23af8f267 100644 --- a/res/values-be/strings.xml +++ b/res/values-be/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Медыя"</string> <string name="storage_description" msgid="4081716890357580107">"Лакальнае сховішча"</string> - <string name="app_label" msgid="9035307001052716210">"Медыясховішча"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Мультымедыя"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Сродак выбару мультымедыя"</string> <string name="artist_label" msgid="8105600993099120273">"Выканаўца"</string> <string name="unknown" msgid="2059049215682829375">"Невядома"</string> <string name="root_images" msgid="5861633549189045666">"Відарысы"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Доступ да воблачных мультымедыя з:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Няма"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Воблачныя мультымедыйныя праграмы не зменены."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Сродак выбару мультымедыя"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Сродак выбару мультымедыя"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Ідзе сінхранізацыя мультымедыя…"</string> <string name="add" msgid="2894574044585549298">"Дадаць"</string> <string name="deselect" msgid="4297825044827769490">"Адмяніць выбар"</string> <string name="deselected" msgid="8488133193326208475">"Выбар скасаваны"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Няма альбомаў"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Праглядзець выбранае"</string> <string name="picker_photos" msgid="7415035516411087392">"Фота"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Альбомы"</string> <string name="picker_preview" msgid="6257414886055861039">"Перадпрагляд"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Пераключыцца на працоўны"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> элемент}one{<xliff:g id="COUNT_1">^1</xliff:g> элемент}few{<xliff:g id="COUNT_1">^1</xliff:g> элементы}many{<xliff:g id="COUNT_1">^1</xliff:g> элементаў}other{<xliff:g id="COUNT_1">^1</xliff:g> элемента}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Дадаць (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Дазволіць (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Не дазваляць ніякія"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Спампоўкі"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Абранае"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Праблемы з прайграваннем відэа"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Праверце падключэнне да інтэрнэту і паўтарыце спробу"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Паўтарыць спробу"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"З\'явіўся доступ да воблачных мультымедыя з праграмы \"<xliff:g id="PKG_NAME">%1$s</xliff:g>\""</string> <string name="not_selected" msgid="2244008151669896758">"не выбраны"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Ідзе падрыхтоўка выбраных вамі медыяфайлаў"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Гатова: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> з <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Скасаваць"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Цяпер дададзены рэзервовыя копіі фотаздымкаў"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Вы можаце выбраць фотаздымкі з уліковага запісу <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> для праграмы \"<xliff:g id="APP_NAME">%1$s</xliff:g>\""</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Зменены ўліковы запіс для праграмы \"<xliff:g id="APP_NAME">%1$s</xliff:g>\""</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Выбраць праграму"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Выбраць уліковы запіс"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Змяніць уліковы запіс"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Вашы фота загружаюцца"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Дазволіць праграме \"<xliff:g id="APP_NAME_0">^1</xliff:g>\" змяніць гэты аўдыяфайл?}one{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайл?}few{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайлы?}many{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайлаў?}other{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> аўдыяфайла?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Змяняецца аўдыяфайл…}one{Змяняецца <xliff:g id="COUNT">^1</xliff:g> аўдыяфайл…}few{Змяняюцца <xliff:g id="COUNT">^1</xliff:g> аўдыяфайлы…}many{Змяняюцца <xliff:g id="COUNT">^1</xliff:g> аўдыяфайлаў…}other{Змяняюцца <xliff:g id="COUNT">^1</xliff:g> аўдыяфайла…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Дазволіць праграме \"<xliff:g id="APP_NAME_0">^1</xliff:g>\" змяніць гэта відэа?}one{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> відэа?}few{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> відэа?}many{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> відэа?}other{Дазволіць праграме \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" змяніць <xliff:g id="COUNT">^2</xliff:g> відэа?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Ахова бяспекі"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Абвесткі пра ўбудаванае перакадзіраванне"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Ход убудаванага перакадзіравання"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Паўтарыце спробу пазней. Калі праблема будзе вырашана, вашы фота стануць даступнымі."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Некаторыя фота не ўдалося загрузіць"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml index fbb9f34c8..9ff2d77c2 100644 --- a/res/values-bg/strings.xml +++ b/res/values-bg/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Мултимедия"</string> <string name="storage_description" msgid="4081716890357580107">"Локално хранилище"</string> - <string name="app_label" msgid="9035307001052716210">"Мултимедийно хранилище"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Мултимедия"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Инструмент за избор на носители"</string> <string name="artist_label" msgid="8105600993099120273">"Изпълнител"</string> <string name="unknown" msgid="2059049215682829375">"Неизвестно"</string> <string name="root_images" msgid="5861633549189045666">"Изображения"</string> @@ -39,16 +38,19 @@ <string name="allow" msgid="8885707816848569619">"Разрешаване"</string> <string name="deny" msgid="6040983710442068936">"Отказ"</string> <string name="picker_browse" msgid="5554477454636075934">"Преглед…"</string> - <string name="picker_settings" msgid="6443463167344790260">"Медийно приложение в облака"</string> + <string name="picker_settings" msgid="6443463167344790260">"Прил. за мултимедия в облака"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Приложение за мултимедия в облака"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Приложение за мултимедия в облака"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Осъществяване на достъп до мултимедията в облака, когато приложение или уебсайт иска от вас да изберете снимки или видеоклипове"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Достъп до мултимедия в облака от"</string> - <string name="picker_settings_no_provider" msgid="2582311853680058223">"Няма"</string> + <string name="picker_settings_no_provider" msgid="2582311853680058223">"Нищо"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Медийното приложение в облака не бе променено."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Инструмент за избор на мултимедия"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Инструмент за избор на мултимедия"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Мултимедията се синхронизира…"</string> <string name="add" msgid="2894574044585549298">"Добавяне"</string> <string name="deselect" msgid="4297825044827769490">"Премахване на избора"</string> - <string name="deselected" msgid="8488133193326208475">"Неизбрано"</string> + <string name="deselected" msgid="8488133193326208475">"Отменен избор"</string> <string name="select" msgid="2704765470563027689">"Избиране"</string> <string name="selected" msgid="9151797369975828124">"Избрано"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Изберете най-много <xliff:g id="COUNT_0">^1</xliff:g> елемент}other{Изберете най-много <xliff:g id="COUNT_1">^1</xliff:g> елемента}}"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Няма албуми"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Преглед на избраното"</string> <string name="picker_photos" msgid="7415035516411087392">"Снимки"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Албуми"</string> <string name="picker_preview" msgid="6257414886055861039">"Визуализация"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Превкл. към служ. пoтр. профил"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Превключване към личния потребителски профил"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Превкл. към личния потр. профил"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Блокирано от администратора ви"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Достъпът до служебни данни от лично приложение не е разрешен"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Достъпът до лични данни от служебно приложение не е разрешен"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> елемент}other{<xliff:g id="COUNT_1">^1</xliff:g> елемента}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Добавяне (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Разрешаване (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Забраняване на всички"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Изтегляния"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Любими"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Проблем при възпроизвеждането на видеосъдържание"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Проверете връзката си с интернет и опитайте отново"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Нов опит"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Вече е налице мултимедия в облака от <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"не е избрано"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Избраната от вас мултимедия се подготвя"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Готови: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> от <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Отказ"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Снимките, за които е създадено резервно копие, вече са добавени"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Можете да избирате снимки от профила <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> в(ъв) <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Профилът в(ъв) <xliff:g id="APP_NAME">%1$s</xliff:g> е актуализиран"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Избиране на приложение"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Избиране на профил"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Промяна на профила"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Всичките ви снимки се извличат"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Да се разреши ли на <xliff:g id="APP_NAME_0">^1</xliff:g> да промени този аудиофайл?}other{Да се разреши ли на <xliff:g id="APP_NAME_1">^1</xliff:g> да промени <xliff:g id="COUNT">^2</xliff:g> аудиофайла?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Аудиофайлът се променя…}other{<xliff:g id="COUNT">^1</xliff:g> аудиофайла се променят…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Да се разреши ли на <xliff:g id="APP_NAME_0">^1</xliff:g> да промени този видеоклип?}other{Да се разреши ли на <xliff:g id="APP_NAME_1">^1</xliff:g> да промени <xliff:g id="COUNT">^2</xliff:g> видеоклипа?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Защита на безопасността"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Стандартни сигнали за прекодиране"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Стандартен прогрес при прекодиране"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Опитайте отново по-късно. Снимките ви ще бъдат налице, след като проблемът бъде разрешен."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Някои снимки не могат да се заредят"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Разбрах"</string> </resources> diff --git a/res/values-bn/strings.xml b/res/values-bn/strings.xml index 2f4826a8f..0d9f12d11 100644 --- a/res/values-bn/strings.xml +++ b/res/values-bn/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"মিডিয়া"</string> <string name="storage_description" msgid="4081716890357580107">"স্থানীয় স্টোরেজ"</string> - <string name="app_label" msgid="9035307001052716210">"মিডিয়া স্টোরেজ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"মিডিয়া"</string> + <string name="picker_app_label" msgid="1195424381053599122">"মিডিয়া বাছাইকারি"</string> <string name="artist_label" msgid="8105600993099120273">"শিল্পী"</string> <string name="unknown" msgid="2059049215682829375">"অজানা"</string> <string name="root_images" msgid="5861633549189045666">"ছবি"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"এখান থেকে ক্লাউড মিডিয়া অ্যাক্সেস করুন"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"কোনওটিই নয়"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"এই মুহূর্তে ক্লাউড মিডিয়া অ্যাপ বদল করা যায়নি।"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"মিডিয়া বাছাইকারী"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"মিডিয়া বাছাইকারী"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"মিডিয়া সিঙ্ক করছে…"</string> <string name="add" msgid="2894574044585549298">"যোগ করুন"</string> <string name="deselect" msgid="4297825044827769490">"টিক চিহ্নটি সরিয়ে দিন"</string> <string name="deselected" msgid="8488133193326208475">"টিকচিহ্ন সরিয়ে দিন"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"কোনও অ্যালবাম নেই"</string> <string name="picker_view_selected" msgid="2266031384396143883">"কোনগুলি বাছা হয়েছে দেখুন"</string> <string name="picker_photos" msgid="7415035516411087392">"ফটো"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"অ্যালবাম"</string> <string name="picker_preview" msgid="6257414886055861039">"প্রিভিউ"</string> <string name="picker_work_profile" msgid="2083221066869141576">"অফিস প্রোফাইলে সুইচ করুন"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g>টি আইটেম}one{<xliff:g id="COUNT_1">^1</xliff:g>টি আইটেম}other{<xliff:g id="COUNT_1">^1</xliff:g>টি আইটেম}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>)টি যোগ করুন"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"অনুমতি দিন (<xliff:g id="COUNT">^1</xliff:g>টি)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"কারও অনুমতি নেই"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ক্যামেরা"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ডাউনলোড করা আইটেম"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"পছন্দসই আইটেম"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ভিডিও প্লে করতে সমস্যা হয়েছে"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ইন্টারনেট কানেকশন ঠিক আছে কিনা দেখে নিয়ে আবার চেষ্টা করুন"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"আবার চেষ্টা করুন"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ক্লাউড মিডিয়া এখন <xliff:g id="PKG_NAME">%1$s</xliff:g> থেকে উপলভ্য"</string> <string name="not_selected" msgid="2244008151669896758">"বেছে নেওয়া হয়নি"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"আপনার বেছে নেওয়া মিডিয়া রেডি করা হচ্ছে"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>টির মধ্যে <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> নম্বর আইটেম রেডি আছে"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"বাতিল করুন"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ব্যাক-আপ নেওয়া ফটো এখন যোগ করা হয়েছে"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"আপনি <xliff:g id="APP_NAME">%1$s</xliff:g>-এর অ্যাকাউন্ট <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> থেকে ফটো বেছে নিতে পারবেন"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-এর অ্যাকাউন্ট আপডেট করা হয়েছে"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"অ্যাপ বেছে নিন"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"অ্যাকাউন্ট বেছে নিন"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"অ্যাকাউন্ট পরিবর্তন করুন"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"আপনার সব ফটো লোড করা হচ্ছে"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>-কে এই অডিও ফাইল পরিবর্তন করার অনুমতি দিতে চান?}one{<xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি অডিও ফাইল পরিবর্তন করার অনুমতি দিতে চান?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি অডিও ফাইল পরিবর্তন করার অনুমতি দিতে চান?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{অডিও ফাইলে পরিবর্তন করা হচ্ছে…}one{<xliff:g id="COUNT">^1</xliff:g>টি অডিও ফাইলে পরিবর্তন করা হচ্ছে…}other{<xliff:g id="COUNT">^1</xliff:g>টি অডিও ফাইলে পরিবর্তন করা হচ্ছে…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>-কে এই ভিডিও পরিবর্তন করার অনুমতি দিতে চান?}one{<xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি ভিডিও পরিবর্তন করার অনুমতি দিতে চান?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>-কে <xliff:g id="COUNT">^2</xliff:g>টি ভিডিও পরিবর্তন করার অনুমতি দিতে চান?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"নিরাপত্তার সুরক্ষা"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"নেটিভ ট্রান্সকোড অ্যালার্ট"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"নেটিভ ট্রান্সকোড প্রোগ্রেস"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"পরে আবার চেষ্টা করুন। সমস্যার সমাধান হয়ে গেলে আপনার ফটো উপলভ্য হবে।"</string> + <string name="dialog_error_title" msgid="636349284077820636">"কিছু ফটো লোড করা যাচ্ছে না"</string> + <string name="dialog_button_text" msgid="351366485240852280">"বুঝেছি"</string> </resources> diff --git a/res/values-bs/strings.xml b/res/values-bs/strings.xml index e04120d8e..557107e7f 100644 --- a/res/values-bs/strings.xml +++ b/res/values-bs/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Mediji"</string> <string name="storage_description" msgid="4081716890357580107">"Lokalna pohrana"</string> - <string name="app_label" msgid="9035307001052716210">"Medijska pohrana"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Medij"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Izbornik medijskog sadržaja"</string> <string name="artist_label" msgid="8105600993099120273">"Umjetnik"</string> <string name="unknown" msgid="2059049215682829375">"Nepoznato"</string> <string name="root_images" msgid="5861633549189045666">"Slike"</string> @@ -39,16 +38,19 @@ <string name="allow" msgid="8885707816848569619">"Dozvoli"</string> <string name="deny" msgid="6040983710442068936">"Odbij"</string> <string name="picker_browse" msgid="5554477454636075934">"Pregledajte…"</string> - <string name="picker_settings" msgid="6443463167344790260">"Apl. za med. sadržaje u oblaku"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Aplikacija za medijske sadržaje u oblaku"</string> + <string name="picker_settings" msgid="6443463167344790260">"Aplikacija za medije u oblaku"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Aplikacija za medije u oblaku"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Aplikacija za medijske sadržaje u oblaku"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Pristupite medijima na oblaku kada vam aplikacija ili web lokacija zatraži da odaberete fotografije ili videozapise"</string> - <string name="picker_settings_selection_message" msgid="245453573086488596">"Pristupite medijskom sadržaju u oblaku iz"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Pristupite medijima u oblaku kada vam aplikacija ili web lokacija zatraži da odaberete fotografije ili videozapise"</string> + <string name="picker_settings_selection_message" msgid="245453573086488596">"Pristupite medijima u oblaku iz oblaku iz"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ništa"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Promjena medijske aplikacije u oblaku nije uspjela."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Izbornik medijskog sadržaja"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Izbornik medijskog sadržaja"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sinhroniziranje medijskog sadržaja…"</string> <string name="add" msgid="2894574044585549298">"Dodaj"</string> <string name="deselect" msgid="4297825044827769490">"Poništi odabir"</string> - <string name="deselected" msgid="8488133193326208475">"Odabir poništen"</string> + <string name="deselected" msgid="8488133193326208475">"Odabir je poništen"</string> <string name="select" msgid="2704765470563027689">"Odaberi"</string> <string name="selected" msgid="9151797369975828124">"Odabrano"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Odaberite najviše <xliff:g id="COUNT_0">^1</xliff:g> stavku}one{Odaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavku}few{Odaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavke}other{Odaberite najviše <xliff:g id="COUNT_1">^1</xliff:g> stavki}}"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nema albuma"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži odabrano"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotografije"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumi"</string> <string name="picker_preview" msgid="6257414886055861039">"Pregled"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Prebacite se na radni"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Prebacite se na lični"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Prebacite se na radni profil"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Prebacite se na lični profil profil"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Blokirao je administrator"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Pristupanje poslovnim podacima iz lične aplikacije nije dozvoljeno"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Pristupanje ličnim podacima iz poslovne aplikacije nije dozvoljeno"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> stavka}one{<xliff:g id="COUNT_1">^1</xliff:g> stavka}few{<xliff:g id="COUNT_1">^1</xliff:g> stavke}other{<xliff:g id="COUNT_1">^1</xliff:g> stavki}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Dodaj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Dozvoli (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nemoj dozvoliti ništa"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Preuzimanja"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Omiljeno"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Poteškoće prilikom reprodukcije videozapisa"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Provjerite internetsku vezu i pokušajte ponovo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Pokušaj ponovo"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Medijski sadržaj u oblaku je sada dostupan od usluge <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nije odabrano"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Pripremanje odabranih medijskih fajlova"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Spremno: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> od <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Otkaži"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Sigurnosne kopije fotografija su sada uključene"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Možete odabrati fotografije s računa <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> u aplikaciji <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Račun u aplikaciji <xliff:g id="APP_NAME">%1$s</xliff:g> je ažuriran"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Zaštita sigurnosti"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Obavještenja o izvornom konvertiranju"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Napredak izvornog konvertiranja"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Pokušajte ponovo kasnije. Fotografije će biti dostupne čim se problem riješi."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nije moguće učitati određene fotografije"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Razumijem"</string> </resources> diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml index 580d157a5..704fc78b2 100644 --- a/res/values-ca/strings.xml +++ b/res/values-ca/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimèdia"</string> <string name="storage_description" msgid="4081716890357580107">"Emmagatzematge local"</string> - <string name="app_label" msgid="9035307001052716210">"Emmagatzematge multimèdia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Contingut multimèdia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Selector de mitjans"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconegut"</string> <string name="root_images" msgid="5861633549189045666">"Imatges"</string> @@ -40,12 +39,15 @@ <string name="deny" msgid="6040983710442068936">"Denega"</string> <string name="picker_browse" msgid="5554477454636075934">"Navega…"</string> <string name="picker_settings" msgid="6443463167344790260">"Aplicació multimèdia al núvol"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Aplicació multimèdia al núvol"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"App multimèdia al núvol"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Aplicació multimèdia al núvol"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Accedeix al contingut multimèdia al núvol si una aplicació o un lloc web et demana que seleccionis fotos o vídeos"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Accedeix al contingut multimèdia al núvol des de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Cap"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"No s\'ha pogut canviar l\'app multimèdia al núvol."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Selector de mitjans"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Selector de mitjans"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"S\'està sincronitzant el contingut multimèdia…"</string> <string name="add" msgid="2894574044585549298">"Afegeix"</string> <string name="deselect" msgid="4297825044827769490">"Desselecciona"</string> <string name="deselected" msgid="8488133193326208475">"Desseleccionat"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No hi ha cap àlbum"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Mostra la selecció"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Àlbums"</string> <string name="picker_preview" msgid="6257414886055861039">"Previsualitza"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Canvia al perfil de treball"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}many{<xliff:g id="COUNT_1">^1</xliff:g> elements}other{<xliff:g id="COUNT_1">^1</xliff:g> elements}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Afegeix (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permet (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"No en permetis cap"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Càmera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Baixades"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Preferits"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Hi ha hagut un problema en reproduir el vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Comprova la connexió a Internet i torna-ho a provar"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Torna-ho a provar"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"El contingut multimèdia al núvol ara està disponible des de <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"no seleccionat"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"S\'està preparant el contingut multimèdia seleccionat"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> a punt"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel·la"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ara s\'ha inclòs la còpia de seguretat de les fotos"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Pots seleccionar fotos del compte de <xliff:g id="APP_NAME">%1$s</xliff:g> de <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"S\'ha actualitzat el compte de <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Tria una aplicació"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Tria un compte"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Canvia de compte"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"S\'estan obtenint totes les teves fotos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vols permetre que <xliff:g id="APP_NAME_0">^1</xliff:g> modifiqui aquest fitxer d\'àudio?}many{Vols permetre que <xliff:g id="APP_NAME_1">^1</xliff:g> modifiqui <xliff:g id="COUNT">^2</xliff:g> fitxers d\'àudio?}other{Vols permetre que <xliff:g id="APP_NAME_1">^1</xliff:g> modifiqui <xliff:g id="COUNT">^2</xliff:g> fitxers d\'àudio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{S\'està modificant el fitxer d\'àudio…}many{S\'estan modificant <xliff:g id="COUNT">^1</xliff:g> fitxers d\'àudio…}other{S\'estan modificant <xliff:g id="COUNT">^1</xliff:g> fitxers d\'àudio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vols permetre que <xliff:g id="APP_NAME_0">^1</xliff:g> modifiqui aquest vídeo?}many{Vols permetre que <xliff:g id="APP_NAME_1">^1</xliff:g> modifiqui <xliff:g id="COUNT">^2</xliff:g> vídeos?}other{Vols permetre que <xliff:g id="APP_NAME_1">^1</xliff:g> modifiqui <xliff:g id="COUNT">^2</xliff:g> vídeos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protecció de seguretat"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertes de transcodificació nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progrés de la transcodificació nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Torna-ho a provar més tard. Les teves fotos estaran disponibles un cop el problema s\'hagi resolt."</string> + <string name="dialog_error_title" msgid="636349284077820636">"No es poden carregar algunes fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entesos"</string> </resources> diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml index 9a919a210..dd3f2af80 100644 --- a/res/values-cs/strings.xml +++ b/res/values-cs/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Média"</string> <string name="storage_description" msgid="4081716890357580107">"Místní úložiště"</string> - <string name="app_label" msgid="9035307001052716210">"Úložiště médií"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Média"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Nástroj pro výběr médií"</string> <string name="artist_label" msgid="8105600993099120273">"Interpret"</string> <string name="unknown" msgid="2059049215682829375">"Neznámý"</string> <string name="root_images" msgid="5861633549189045666">"Obrázky"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Aplikace pro cloudová média"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Aplikace pro cloudová média"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Aplikace pro cloudová média"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Když vás aplikace nebo web požádá o výběr fotografií nebo videí, přejít na vaše cloudová média"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Když vás aplikace nebo web požádá o výběr fotografií nebo videí, můžete přejít na svoje cloudová média"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Přístup ke cloudovým médiím z"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Žádný"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Aplikaci pro cloudová média nyní nelze změnit."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Nástroj pro výběr médií"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Nástroj pro výběr médií"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synchronizace médií…"</string> <string name="add" msgid="2894574044585549298">"Přidat"</string> <string name="deselect" msgid="4297825044827769490">"Zrušit výběr"</string> <string name="deselected" msgid="8488133193326208475">"Výběr zrušen"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Žádná alba"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Zobrazit vybrané"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotky"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Alba"</string> <string name="picker_preview" msgid="6257414886055861039">"Náhled"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Přepnout na pracovní profil"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> položka}few{<xliff:g id="COUNT_1">^1</xliff:g> položky}many{<xliff:g id="COUNT_1">^1</xliff:g> položky}other{<xliff:g id="COUNT_1">^1</xliff:g> položek}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Přidat (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Povolit (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nepovolit nic"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Fotoaparát"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Stažené soubory"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Oblíbené"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Při přehrávání videa došlo k potížím"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Zkontrolujte připojení k internetu a zkuste to znovu"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Zkusit znovu"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloudová média jsou teď k dispozici ze zdroje <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nevybráno"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Příprava vámi vybraných médií"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Připraveno: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> z <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Zrušit"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Teď jsou zde zahrnuty zálohované fotky"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Můžete vybrat fotky z účtu <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> aplikace <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Účet <xliff:g id="APP_NAME">%1$s</xliff:g> byl aktualizován"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Vybrat aplikaci"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Vybrat účet"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Změnit účet"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Načítání všech fotek"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Povolit aplikaci <xliff:g id="APP_NAME_0">^1</xliff:g> upravit tento zvukový soubor?}few{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> zvukové soubory?}many{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> zvukového souboru?}other{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> zvukových souborů?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Úprava zvukového souboru…}few{Úprava <xliff:g id="COUNT">^1</xliff:g> zvukových souborů…}many{Úprava <xliff:g id="COUNT">^1</xliff:g> zvukového souboru…}other{Úprava <xliff:g id="COUNT">^1</xliff:g> zvukových souborů…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Povolit aplikaci <xliff:g id="APP_NAME_0">^1</xliff:g> upravit toto video?}few{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> videa?}many{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> videa?}other{Povolit aplikaci <xliff:g id="APP_NAME_1">^1</xliff:g> upravit <xliff:g id="COUNT">^2</xliff:g> videí?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Bezpečnostní ochrana"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Upozornění na nativní překódování"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Průběh nativního překódování"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Zkuste to později. Fotky budou k dispozici po vyřešení tohoto problému."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Některé fotografie nelze načíst"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Rozumím"</string> </resources> diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml index 0d72f654c..fbf5071f4 100644 --- a/res/values-da/strings.xml +++ b/res/values-da/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Medier"</string> <string name="storage_description" msgid="4081716890357580107">"Lokalt lager"</string> - <string name="app_label" msgid="9035307001052716210">"Medielagring"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Mediefiler"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Medievælger"</string> <string name="artist_label" msgid="8105600993099120273">"Kunstner"</string> <string name="unknown" msgid="2059049215682829375">"Ukendt"</string> <string name="root_images" msgid="5861633549189045666">"Billeder"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Få adgang til medier i skyen via"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ingen"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Skymedieappen kunne ikke ændres"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Medievælger"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Medievælger"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Mediet synkroniseres…"</string> <string name="add" msgid="2894574044585549298">"Tilføj"</string> <string name="deselect" msgid="4297825044827769490">"Fravælg"</string> <string name="deselected" msgid="8488133193326208475">"Fravalgt"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ingen album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Se valgte"</string> <string name="picker_photos" msgid="7415035516411087392">"Billeder"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Forhåndsvisning"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Skift til arbejdsprofil"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}one{<xliff:g id="COUNT_1">^1</xliff:g> element}other{<xliff:g id="COUNT_1">^1</xliff:g> elementer}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Tilføj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Tillad (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Tillad ingen"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritter"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problemer med at afspille video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Tjek din internetforbindelse, og prøv igen"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Prøv igen"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Medier i skyen er nu tilgængelige fra <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"ikke valgt"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Dine valgte medier gøres klar"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> af <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> er klar"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Annuller"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Sikkerhedskopierede billeder er nu inkluderet"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Du kan vælge billeder fra <xliff:g id="APP_NAME">%1$s</xliff:g>-kontoen <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-kontoen er opdateret"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Vælg app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Vælg en konto"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Skift konto"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Indlæser alle dine billeder"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vil du give <xliff:g id="APP_NAME_0">^1</xliff:g> tilladelse til at ændre denne lydfil?}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?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Ændrer lydfilen…}one{Ændrer <xliff:g id="COUNT">^1</xliff:g> lydfil…}other{Ændrer <xliff:g id="COUNT">^1</xliff:g> lydfiler…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vil du give <xliff:g id="APP_NAME_0">^1</xliff:g> tilladelse til at ændre denne video?}one{Vil du give <xliff:g id="APP_NAME_1">^1</xliff:g> tilladelse til at ændre <xliff:g id="COUNT">^2</xliff:g> video?}other{Vil du give <xliff:g id="APP_NAME_1">^1</xliff:g> tilladelse til at ændre <xliff:g id="COUNT">^2</xliff:g> videoer?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Beskyttelse"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Underretninger om indbygget omkodning"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Status på indbygget omkodning"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Prøv igen senere. Dine billeder bliver tilgængelige, så snart problemet er løst."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nogle billeder kan ikke indlæses"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml index b7645d32e..658e3f088 100644 --- a/res/values-de/strings.xml +++ b/res/values-de/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Medien"</string> <string name="storage_description" msgid="4081716890357580107">"Lokaler Speicher"</string> - <string name="app_label" msgid="9035307001052716210">"Medienspeicher"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Medien"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media-Auswahl"</string> <string name="artist_label" msgid="8105600993099120273">"Interpret"</string> <string name="unknown" msgid="2059049215682829375">"Unbekannt"</string> <string name="root_images" msgid="5861633549189045666">"Bilder"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Cloud-Medien-App"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Cloud-Medien-App"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Cloud-Medien-App"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Zugriff auf Cloudmedien, wenn dich eine App oder Website darum bittet, Fotos oder Videos auszuwählen"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Zugriff auf Cloud-Medien, wenn dich eine App oder Website darum bittet, Fotos oder Videos auszuwählen"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Zugriff auf Cloud-Medien aus"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Keine"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Ändern der Cloud-Medien-App derzeit nicht möglich."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media-Auswahl"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media-Auswahl"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Medien werden synchronisiert…"</string> <string name="add" msgid="2894574044585549298">"Hinzufügen"</string> <string name="deselect" msgid="4297825044827769490">"Auswahl aufheben"</string> <string name="deselected" msgid="8488133193326208475">"Auswahl aufgehoben"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Keine Alben"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Auswahl ansehen"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Alben"</string> <string name="picker_preview" msgid="6257414886055861039">"Vorschau"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Zum Arbeitsprofil wechseln"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> Element}other{<xliff:g id="COUNT_1">^1</xliff:g> Elemente}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Hinzufügen (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Erlauben (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Keines zulassen"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoriten"</string> @@ -92,23 +97,23 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Probleme beim Abspielen des Videos"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Prüfe deine Internetverbindung und versuche es noch einmal"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Wiederholen"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud-Medien sind jetzt verfügbar über <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nicht ausgewählt"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Ausgewählte Medien werden vorbereitet"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> von <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> fertig"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Abbrechen"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Gesicherte Fotos jetzt mit berücksichtigt"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Du kannst Fotos aus dem <xliff:g id="APP_NAME">%1$s</xliff:g>-Konto <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> auswählen"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-Konto aktualisiert"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Fotos von <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> sind jetzt hier mit berücksichtigt"</string> - <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Cloudmedien-App auswählen"</string> - <string name="picker_banner_cloud_choose_app_desc" msgid="2359212653555524926">"Damit gesicherte Fotos hier mit berücksichtigt werden, wähle eine Cloudmedien-App in den Einstellungen aus"</string> + <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Cloud-Medien-App auswählen"</string> + <string name="picker_banner_cloud_choose_app_desc" msgid="2359212653555524926">"Damit gesicherte Fotos hier mit berücksichtigt werden, wähle eine Cloud-Medien-App in den Einstellungen aus"</string> <string name="picker_banner_cloud_choose_account_title" msgid="5010901185639577685">"<xliff:g id="APP_NAME">%1$s</xliff:g>-Konto auswählen"</string> <string name="picker_banner_cloud_choose_account_desc" msgid="8868134443673142712">"Damit Fotos von <xliff:g id="APP_NAME">%1$s</xliff:g> hier mit berücksichtigt werden, wähle eine Konto in der App aus"</string> <string name="picker_banner_cloud_dismiss_button" msgid="2935903078288463882">"Schließen"</string> <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"App auswählen"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Konto auswählen"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Konto ändern"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Alle deine Fotos werden geladen"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Darf <xliff:g id="APP_NAME_0">^1</xliff:g> diese Audiodatei ändern?}other{Darf <xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> Audiodateien ändern?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Audiodatei wird geändert…}other{<xliff:g id="COUNT">^1</xliff:g> Audiodateien werden geändert…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Darf <xliff:g id="APP_NAME_0">^1</xliff:g> dieses Video ändern?}other{Darf <xliff:g id="APP_NAME_1">^1</xliff:g> <xliff:g id="COUNT">^2</xliff:g> Videos ändern?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Schutz"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Warnmeldungen bei nativer Transcodierung"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Fortschritt bei nativer Transcodierung"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Versuch es später noch einmal. Deine Fotos sind verfügbar, sobald das Problem gelöst wurde."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Einige Fotos konnten nicht geladen werden"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Ok"</string> </resources> diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml index 3f4fb52f3..695717211 100644 --- a/res/values-el/strings.xml +++ b/res/values-el/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Μέσα"</string> <string name="storage_description" msgid="4081716890357580107">"Τοπικός χώρος αποθήκευσης"</string> - <string name="app_label" msgid="9035307001052716210">"Αποθηκευτικός χώρος μέσων"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Μέσα"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Εργαλείο επιλογής μέσων"</string> <string name="artist_label" msgid="8105600993099120273">"Καλλιτέχνης"</string> <string name="unknown" msgid="2059049215682829375">"Άγνωστο"</string> <string name="root_images" msgid="5861633549189045666">"Εικόνες"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Πρόσβαση σε μέσα στο cloud από"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Καμία"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Η αλλαγή της εφαρμογής μέσων cloud ήταν αδύνατη."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Εργαλείο επιλογής μέσων"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Εργαλείο επιλογής μέσων"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Συγχρονισμός μέσων…"</string> <string name="add" msgid="2894574044585549298">"Προσθήκη"</string> <string name="deselect" msgid="4297825044827769490">"Αποεπιλογή"</string> <string name="deselected" msgid="8488133193326208475">"Αποεπιλέχθηκε"</string> @@ -55,9 +57,11 @@ <string name="recent" msgid="6694613584743207874">"Πρόσφατα"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"Δεν υπάρχουν φωτογραφίες ή βίντεο"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Δεν υπάρχουν υποστηριζόμενες φωτογραφίες ή βίντεο"</string> - <string name="picker_albums_empty_message" msgid="8341079772950966815">"Δεν υπάρχουν λευκώματα"</string> + <string name="picker_albums_empty_message" msgid="8341079772950966815">"Δεν υπάρχουν άλμπουμ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Προβολή επιλεγμένων"</string> <string name="picker_photos" msgid="7415035516411087392">"Φωτογραφίες"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Άλμπουμ"</string> <string name="picker_preview" msgid="6257414886055861039">"Προεπισκόπηση"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Μετάβαση σε προφίλ εργασίας"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> στοιχείο}other{<xliff:g id="COUNT_1">^1</xliff:g> στοιχεία}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Προσθήκη (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Αποδοχή (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Δεν επιτρέπεται καμία"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Κάμερα"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Λήψεις"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Αγαπημένα"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Πρόβλημα με την αναπαραγωγή βίντεο"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Επανάληψη"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Τα μέσα cloud είναι πλέον διαθέσιμα από την εφαρμογή <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"μη επιλεγμένο"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Προετοιμασία των μέσων που επιλέξατε"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> από <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> έτοιμα"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Ακύρωση"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Συμπεριλαμβάνονται πλέον φωτογραφίες που έχουν αντίγραφα ασφαλείας"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Μπορείτε να επιλέξετε φωτογραφίες από τον λογαριασμό <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> στην εφαρμογή <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Ο λογαριασμός <xliff:g id="APP_NAME">%1$s</xliff:g> ενημερώθηκε"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Επιλογή εφαρμογής"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Επιλογή λογαριασμού"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Αλλαγή λογαριασμού"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Γίνεται λήψη όλων των φωτογραφιών σας"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_0">^1</xliff:g> η τροποποίηση αυτού του αρχείου ήχου;}other{Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_1">^1</xliff:g> η τροποποίηση <xliff:g id="COUNT">^2</xliff:g> αρχείων ήχου;}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Τροποποίηση αρχείου ήχου…}other{Τροποποίηση <xliff:g id="COUNT">^1</xliff:g> αρχείων ήχου…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_0">^1</xliff:g> η τροποποίηση αυτού του βίντεο;}other{Να επιτραπεί στην εφαρμογή <xliff:g id="APP_NAME_1">^1</xliff:g> η τροποποίηση <xliff:g id="COUNT">^2</xliff:g> βίντεο;}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety Protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Ειδοποιήσεις εγγενούς διακωδικοποίησης"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Πρόοδος εγγενούς διακωδικοποίησης"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Δοκιμάστε ξανά αργότερα. Οι φωτογραφίες σας θα καταστούν διαθέσιμες μόλις επιλυθεί το πρόβλημα."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Δεν είναι δυνατή η φόρτωση ορισμένων φωτογραφιών"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Το κατάλαβα"</string> </resources> diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml index 3b6aab207..afa1aa472 100644 --- a/res/values-en-rAU/strings.xml +++ b/res/values-en-rAU/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Local storage"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media picker"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Unknown"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Access cloud media from"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"None"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Could not change cloud media app at this time."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media Picker"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media Picker"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Syncing media…"</string> <string name="add" msgid="2894574044585549298">"Add"</string> <string name="deselect" msgid="4297825044827769490">"Deselect"</string> <string name="deselected" msgid="8488133193326208475">"Deselected"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Switch to work"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Add (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Allow (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Allow none"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favourites"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Trouble playing video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Please check your Internet connection and try again"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Retry"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud media now available from <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"not selected"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparing your selected media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> of <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ready"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Backed up photos now included"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"You can select photos from <xliff:g id="APP_NAME">%1$s</xliff:g> account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> account updated"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native transcode alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native transcode progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Please try again later. Your photos will be available once the issue is resolved."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Can\'t load some photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Got it"</string> </resources> diff --git a/res/values-en-rCA/strings.xml b/res/values-en-rCA/strings.xml index 97981a54a..4470b4347 100644 --- a/res/values-en-rCA/strings.xml +++ b/res/values-en-rCA/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Local storage"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media picker"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Unknown"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Access cloud media from"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"None"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Could not change cloud media app at this time."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media picker"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media picker"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Syncing media…"</string> <string name="add" msgid="2894574044585549298">"Add"</string> <string name="deselect" msgid="4297825044827769490">"Deselect"</string> <string name="deselected" msgid="8488133193326208475">"Deselected"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Switch to work"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Add (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Allow (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Allow none"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favorites"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Trouble playing video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Check your internet connection and try again"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Retry"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud media now available from <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"not selected"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparing your selected media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> of <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ready"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Backed up photos now included"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"You can select photos from <xliff:g id="APP_NAME">%1$s</xliff:g> account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> account updated"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Try again later. Your photos will be available once the issue is resolved."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Can\'t load some Photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Got it"</string> </resources> diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml index 3b6aab207..afa1aa472 100644 --- a/res/values-en-rGB/strings.xml +++ b/res/values-en-rGB/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Local storage"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media picker"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Unknown"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Access cloud media from"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"None"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Could not change cloud media app at this time."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media Picker"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media Picker"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Syncing media…"</string> <string name="add" msgid="2894574044585549298">"Add"</string> <string name="deselect" msgid="4297825044827769490">"Deselect"</string> <string name="deselected" msgid="8488133193326208475">"Deselected"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Switch to work"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Add (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Allow (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Allow none"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favourites"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Trouble playing video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Please check your Internet connection and try again"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Retry"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud media now available from <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"not selected"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparing your selected media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> of <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ready"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Backed up photos now included"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"You can select photos from <xliff:g id="APP_NAME">%1$s</xliff:g> account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> account updated"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native transcode alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native transcode progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Please try again later. Your photos will be available once the issue is resolved."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Can\'t load some photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Got it"</string> </resources> diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml index 3b6aab207..afa1aa472 100644 --- a/res/values-en-rIN/strings.xml +++ b/res/values-en-rIN/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Local storage"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media picker"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Unknown"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Access cloud media from"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"None"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Could not change cloud media app at this time."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media Picker"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media Picker"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Syncing media…"</string> <string name="add" msgid="2894574044585549298">"Add"</string> <string name="deselect" msgid="4297825044827769490">"Deselect"</string> <string name="deselected" msgid="8488133193326208475">"Deselected"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Switch to work"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Add (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Allow (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Allow none"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favourites"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Trouble playing video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Please check your Internet connection and try again"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Retry"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud media now available from <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"not selected"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparing your selected media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> of <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ready"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Backed up photos now included"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"You can select photos from <xliff:g id="APP_NAME">%1$s</xliff:g> account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> account updated"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native transcode alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native transcode progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Please try again later. Your photos will be available once the issue is resolved."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Can\'t load some photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Got it"</string> </resources> diff --git a/res/values-en-rXC/strings.xml b/res/values-en-rXC/strings.xml index 86667071b..100a9782a 100644 --- a/res/values-en-rXC/strings.xml +++ b/res/values-en-rXC/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Local storage"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Media picker"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Unknown"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Access cloud media from"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"None"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Could not change cloud media app at this time."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Media picker"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Media picker"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Syncing media…"</string> <string name="add" msgid="2894574044585549298">"Add"</string> <string name="deselect" msgid="4297825044827769490">"Deselect"</string> <string name="deselected" msgid="8488133193326208475">"Deselected"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"View selected"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Switch to work"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Add (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Allow (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Allow none"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favorites"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Trouble playing video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Check your internet connection and try again"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Retry"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloud media now available from <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"not selected"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparing your selected media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> of <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ready"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancel"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Backed up photos now included"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"You can select photos from <xliff:g id="APP_NAME">%1$s</xliff:g> account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> account updated"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Safety protection"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Try again later. Your photos will be available once the issue is resolved."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Can\'t load some Photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Got it"</string> </resources> diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml index 3c244c9cb..03771b7cc 100644 --- a/res/values-es-rUS/strings.xml +++ b/res/values-es-rUS/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia"</string> <string name="storage_description" msgid="4081716890357580107">"Almacenamiento local"</string> - <string name="app_label" msgid="9035307001052716210">"Almacenamiento multimedia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimedia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Selector de medios"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconocido"</string> <string name="root_images" msgid="5861633549189045666">"Imágenes"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Accede a los medios en la nube desde"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ninguna"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"No se pudo cambiar la app de música en la nube."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Selector de medios"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Selector de medios"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizando contenido multimedia…"</string> <string name="add" msgid="2894574044585549298">"Agregar"</string> <string name="deselect" msgid="4297825044827769490">"Anular la selección"</string> <string name="deselected" msgid="8488133193326208475">"Sin seleccionar"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"No hay álbumes"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ver seleccionados"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbumes"</string> <string name="picker_preview" msgid="6257414886055861039">"Vista previa"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Cambiar al perfil de trabajo"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elemento}many{<xliff:g id="COUNT_1">^1</xliff:g> elementos}other{<xliff:g id="COUNT_1">^1</xliff:g> elementos}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Agregar (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"No permitir ninguna"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Cámara"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Descargas"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Se produjo un problema al reproducir el video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Revisa la conexión a Internet y vuelve a intentarlo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Reintentar"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Contenido multimedia en la nube ahora disponible desde <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"sin seleccionar"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparando el contenido multimedia seleccionado"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> listos"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ahora se incluyen las fotos con copia de seguridad"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puedes seleccionar fotos de <xliff:g id="APP_NAME">%1$s</xliff:g> desde la cuenta de <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puedes seleccionar imágenes de <xliff:g id="APP_NAME">%1$s</xliff:g> desde la cuenta de <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Se actualizó la cuenta de <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Ahora se incluyen aquí las fotos de <xliff:g id="USER_ACCOUNT">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Elige una app multimedia en la nube"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Elegir una app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Elegir cuenta"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Cambiar cuenta"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Obteniendo todas tus fotos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{¿Deseas permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este archivo de audio?}many{¿Deseas permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?}other{¿Deseas permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modificando el archivo de audio…}many{Modificando <xliff:g id="COUNT">^1</xliff:g> archivos de audio…}other{Modificando <xliff:g id="COUNT">^1</xliff:g> archivos de audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{¿Deseas permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este video?}many{¿Deseas permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> videos?}other{¿Deseas permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> videos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protección de seguridad"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Vuelve a intentarlo más tarde. Tus fotos estarán disponibles una vez que se resuelva el problema."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Se produjo un error durante la carga de algunas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entendido"</string> </resources> diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml index d407a5c08..532b10b71 100644 --- a/res/values-es/strings.xml +++ b/res/values-es/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia"</string> <string name="storage_description" msgid="4081716890357580107">"Almacenamiento local"</string> - <string name="app_label" msgid="9035307001052716210">"Almacenamiento multimedia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimedia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Selector de medios"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconocido"</string> <string name="root_images" msgid="5861633549189045666">"Imágenes"</string> @@ -46,22 +45,27 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Accede al contenido multimedia en la nube desde"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ninguna"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"No se puede cambiar la app multimedia en la nube."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Selector de medios"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Selector de medios"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizando contenido multimedia…"</string> <string name="add" msgid="2894574044585549298">"Añadir"</string> <string name="deselect" msgid="4297825044827769490">"Desmarcar"</string> <string name="deselected" msgid="8488133193326208475">"Desmarcado"</string> <string name="select" msgid="2704765470563027689">"Seleccionar"</string> <string name="selected" msgid="9151797369975828124">"Seleccionado"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Selecciona hasta <xliff:g id="COUNT_0">^1</xliff:g> elemento}many{Selecciona hasta <xliff:g id="COUNT_1">^1</xliff:g> elementos}other{Selecciona hasta <xliff:g id="COUNT_1">^1</xliff:g> elementos}}"</string> - <string name="recent" msgid="6694613584743207874">"Reciente"</string> + <string name="recent" msgid="6694613584743207874">"Recientes"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"No hay fotos ni vídeos"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"No hay fotos ni vídeos compatibles"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"No hay ningún álbum"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ver seleccionado"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbumes"</string> <string name="picker_preview" msgid="6257414886055861039">"Vista previa"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Cambiar al de trabajo"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Cambiar al personal"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Cambiar a perfil de trabajo"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Cambiar a perfil personal"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bloqueado por tu administrador"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"No se puede acceder a datos de trabajo desde una aplicación personal"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"No se puede acceder a datos personales desde una aplicación de trabajo"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elemento}many{<xliff:g id="COUNT_1">^1</xliff:g> elementos}other{<xliff:g id="COUNT_1">^1</xliff:g> elementos}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Añadir (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"No permitir ninguna"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Cámara"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Descargas"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Hay problemas para reproducir el vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Comprueba tu conexión a Internet y vuelve a intentarlo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Reintentar"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Contenido multimedia en la nube ahora disponible desde <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"no seleccionado"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparando el contenido multimedia seleccionado"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> listos"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ahora se incluye la copia de seguridad de las fotos"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puedes seleccionar fotos de la cuenta de <xliff:g id="APP_NAME">%1$s</xliff:g> de <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Cuenta de <xliff:g id="APP_NAME">%1$s</xliff:g> actualizada"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Elegir aplicación"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Elegir cuenta"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Cambiar de cuenta"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Cargando todas tus fotos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{¿Permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este archivo de audio?}many{¿Permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?}other{¿Permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> archivos de audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modificando archivo de audio…}many{Modificando <xliff:g id="COUNT">^1</xliff:g> archivos de audio…}other{Modificando <xliff:g id="COUNT">^1</xliff:g> archivos de audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{¿Permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este vídeo?}many{¿Permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> vídeos?}other{¿Permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> vídeos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protección de seguridad"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertas de transcodificación nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progreso de transcodificación nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Inténtalo de nuevo más tarde. Tus fotos estarán disponibles cuando se resuelva el problema."</string> + <string name="dialog_error_title" msgid="636349284077820636">"No se pueden cargar algunas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entendido"</string> </resources> diff --git a/res/values-et/strings.xml b/res/values-et/strings.xml index 39a435abc..51ff30940 100644 --- a/res/values-et/strings.xml +++ b/res/values-et/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Meedia"</string> <string name="storage_description" msgid="4081716890357580107">"Kohalik salvestusruum"</string> - <string name="app_label" msgid="9035307001052716210">"Meediumi salvestusruum"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Meedia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Meediavalija"</string> <string name="artist_label" msgid="8105600993099120273">"Esitaja"</string> <string name="unknown" msgid="2059049215682829375">"Teadmata"</string> <string name="root_images" msgid="5861633549189045666">"Pildid"</string> @@ -39,13 +38,16 @@ <string name="allow" msgid="8885707816848569619">"Luba"</string> <string name="deny" msgid="6040983710442068936">"Keela"</string> <string name="picker_browse" msgid="5554477454636075934">"Sirvimine …"</string> - <string name="picker_settings" msgid="6443463167344790260">"Pilvemeediarakendus"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Pilvemeediarakendus"</string> - <string name="picker_settings_title" msgid="5647700706470673258">"Pilvemeediarakendus"</string> + <string name="picker_settings" msgid="6443463167344790260">"Pilvemeedia rakendus"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Pilvemeedia rakendus"</string> + <string name="picker_settings_title" msgid="5647700706470673258">"Pilvemeedia rakendus"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Juurdepääs teie pilves olevale meediale, kui rakendus või veebisait palub teil fotosid või videoid valida"</string> - <string name="picker_settings_selection_message" msgid="245453573086488596">"Pilvemeediarakendus:"</string> + <string name="picker_settings_selection_message" msgid="245453573086488596">"Pilvemeedia rakendus:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Pole"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Pilvepõhist meediarakendust ei saanud muuta."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Meediavalija"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Meediavalija"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Meediumi sünkroonimine …"</string> <string name="add" msgid="2894574044585549298">"Lisa"</string> <string name="deselect" msgid="4297825044827769490">"Tühista valik"</string> <string name="deselected" msgid="8488133193326208475">"Valik on tühistatud"</string> @@ -58,12 +60,14 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Albumeid pole"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Kuva valitud"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotod"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumid"</string> <string name="picker_preview" msgid="6257414886055861039">"Eelvaade"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Lülituge tööprofiilile"</string> <string name="picker_personal_profile" msgid="639484258397758406">"Lülituge isiklikule profiilile"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Blokeeris teie administraator"</string> - <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Juurdepääs tööandmetele isikliku rakenduse kaudu pole lubatud"</string> + <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Juurdepääs tööandmetele isikliku rakenduse kaudu pole lubatud."</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Juurdepääs isiklikele andmetele töörakenduse kaudu pole lubatud"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Töörakendused on peatatud"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"Tööfotode avamiseks lülitage töörakendused sisse ja proovige uuesti"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> üksus}other{<xliff:g id="COUNT_1">^1</xliff:g> üksust}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Lisa (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Luba (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ära luba ühtki"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kaamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Allalaadimised"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Lemmikud"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Probleem video esitamisel"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Kontrollige internetiühendust ja proovige uuesti"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Proovi uuesti"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Pilvemeedia on nüüd rakenduse <xliff:g id="PKG_NAME">%1$s</xliff:g> kaudu saadaval"</string> <string name="not_selected" msgid="2244008151669896758">"pole valitud"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Valitud meedia ettevalmistamine"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>-st on valmis"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Tühista"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Varundatud fotod on nüüd kaasatud"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Saate valida fotosid rakendusest <xliff:g id="APP_NAME">%1$s</xliff:g> konto <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> kaudu"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Saate valida konto <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> fotosid rakendusest <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Rakenduse <xliff:g id="APP_NAME">%1$s</xliff:g> kontot värskendati"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Kasutaja <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> fotod on nüüd siia kaasatud"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Valige pilvemeediarakendus"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Ohutuskaitse"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Omakoodi transkodeerimise hoiatused"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Omakoodi transkodeerimise edenemine"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Proovige hiljem uuesti. Teie fotod on saadaval pärast probleemi lahendamist."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Mõnda fotot ei saa laadida"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Selge"</string> </resources> diff --git a/res/values-eu/strings.xml b/res/values-eu/strings.xml index 10f871df9..be1db85b5 100644 --- a/res/values-eu/strings.xml +++ b/res/values-eu/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia-edukia"</string> <string name="storage_description" msgid="4081716890357580107">"Biltegi lokala"</string> - <string name="app_label" msgid="9035307001052716210">"Multimediaren memoria-unitatea"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimedia-edukia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Multimedia-edukiaren hautatzailea"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Ezezaguna"</string> <string name="root_images" msgid="5861633549189045666">"Irudiak"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Atzitu honen bidez gordetako hodeiko multimedia-edukia:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Bat ere ez"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Ezin izan da aldatu hodeiko multimedia-aplikazioa."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Multimedia-edukiaren hautatzailea"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Multimedia-edukiaren hautatzailea"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Multimedia-edukia sinkronizatzen…"</string> <string name="add" msgid="2894574044585549298">"Gehitu"</string> <string name="deselect" msgid="4297825044827769490">"Desautatu"</string> <string name="deselected" msgid="8488133193326208475">"Desautatuta"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ez dago albumik"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ikusi hautatutakoak"</string> <string name="picker_photos" msgid="7415035516411087392">"Argazkiak"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumak"</string> <string name="picker_preview" msgid="6257414886055861039">"Aurrebista"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Aldatu laneko profilera"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elementu}other{<xliff:g id="COUNT_1">^1</xliff:g> elementu}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Gehitu (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Eman baimena (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ez eman baimenik"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Deskargak"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Gogokoak"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Arazoren bat izan da bideoa erreproduzitzean"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Egiaztatu Internetera konektatuta zaudela eta saiatu berriro"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Saiatu berriro"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Hodeiko multimedia edukia <xliff:g id="PKG_NAME">%1$s</xliff:g> bidez atzi daiteke orain"</string> <string name="not_selected" msgid="2244008151669896758">"hautatu gabe"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Hautatutako multimedia-edukia prestatzen"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> prest"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Orain, babeskopiak dituzten argazkiak sartuta daude"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Utzi"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Orain, barnean hartzen dira babeskopiak dituzten argazkiak"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> aplikazioko <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> kontuko argazkiak hauta ditzakezu"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Eguneratu da <xliff:g id="APP_NAME">%1$s</xliff:g> aplikazioko kontua"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Orain, <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> kontuko argazkiak hemen sartuta daude"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Aukeratu aplikazio bat"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Aukeratu kontu bat"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Aldatu kontua"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Argazki guztiak eskuratzen"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Audio-fitxategiari aldaketak egiteko baimena eman nahi diozu <xliff:g id="APP_NAME_0">^1</xliff:g> aplikazioari?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Audio-fitxategia aldatzen…}other{<xliff:g id="COUNT">^1</xliff:g> audio-fitxategi aldatzen…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Bideoari aldaketak egiteko baimena eman nahi diozu <xliff:g id="APP_NAME_0">^1</xliff:g> aplikazioari?}other{<xliff:g id="COUNT">^2</xliff:g> bideori aldaketak egiteko baimena eman nahi diozu <xliff:g id="APP_NAME_1">^1</xliff:g> aplikazioari?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Segurtasun-babesa"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Transkodetze-alerta natiboak"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Transkodetze natiboaren garapena"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Saiatu berriro geroago. Arazoa konpondu ondoren egongo dira erabilgarri argazkiak."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Ezin dira kargatu argazki batzuk"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Ados"</string> </resources> diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml index c6bd780b1..5e515e003 100644 --- a/res/values-fa/strings.xml +++ b/res/values-fa/strings.xml @@ -18,12 +18,11 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"رسانه"</string> <string name="storage_description" msgid="4081716890357580107">"فضای ذخیرهسازی محلی"</string> - <string name="app_label" msgid="9035307001052716210">"فضای ذخیرهسازی رسانه"</string> - <string name="picker_app_label" msgid="4254039089502164761">"رسانه"</string> + <string name="picker_app_label" msgid="1195424381053599122">"انتخابگر رسانه"</string> <string name="artist_label" msgid="8105600993099120273">"هنرمند"</string> <string name="unknown" msgid="2059049215682829375">"نامشخص"</string> <string name="root_images" msgid="5861633549189045666">"تصویر"</string> - <string name="root_videos" msgid="8792703517064649453">"ویدئو"</string> + <string name="root_videos" msgid="8792703517064649453">"ویدیوها"</string> <string name="root_audio" msgid="3505830755201326018">"صوت"</string> <string name="root_documents" msgid="3829103301363849237">"اسناد"</string> <string name="permission_required" msgid="1460820436132943754">"برای اصلاح یا حذف این مورد مجوز لازم است."</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"دسترسی به رسانه ابری از"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"هیچکدام"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"اکنون نمیتوانید برنامه رسانه ابری را تغییر دهید."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"انتخابگر رسانه"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"انتخابگر رسانه"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"درحال همگامسازی رسانه…"</string> <string name="add" msgid="2894574044585549298">"افزودن"</string> <string name="deselect" msgid="4297825044827769490">"لغو انتخاب"</string> <string name="deselected" msgid="8488133193326208475">"لغو انتخابشده"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"آلبومی موجود نیست"</string> <string name="picker_view_selected" msgid="2266031384396143883">"مشاهده موارد انتخابشده"</string> <string name="picker_photos" msgid="7415035516411087392">"عکسها"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"آلبومها"</string> <string name="picker_preview" msgid="6257414886055861039">"پیشنما"</string> <string name="picker_work_profile" msgid="2083221066869141576">"رفتن به نمایه کاری"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> مورد}one{<xliff:g id="COUNT_1">^1</xliff:g> مورد}other{<xliff:g id="COUNT_1">^1</xliff:g> مورد}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"افزودن (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"اجازه دادن (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"مجاز کردن عدم بارگذاری"</string> <string name="picker_category_camera" msgid="4857367052026843664">"دوربین"</string> <string name="picker_category_downloads" msgid="793866660287361900">"بارگیریها"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"موارد دلخواه"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"پخش ویدیو با مشکل روبهرو شد"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"اتصال اینترنت را بررسی کنید و دوباره امتحان کنید"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"امتحان مجدد"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"رسانه ابری اکنون از <xliff:g id="PKG_NAME">%1$s</xliff:g> دردسترس است"</string> <string name="not_selected" msgid="2244008151669896758">"انتخاب نشده است"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"درحال آمادهسازی رسانه انتخابشده"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> مورد از <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> مورد آماده است"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"عکسهای پشتیبانگیریشده اکنون اضافه میشود"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"لغو کردن"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"عکسهای پشتیبانگیریشده اکنون اضافه شدهاند"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"میتوانید عکسهای حساب <xliff:g id="APP_NAME">%1$s</xliff:g> (<xliff:g id="USER_ACCOUNT">%2$s</xliff:g>) را انتخاب کنید"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"حساب <xliff:g id="APP_NAME">%1$s</xliff:g> بهروز شد"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"عکسهای <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> اکنون در اینجا اضافه میشود"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"انتخاب برنامه"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"انتخاب حساب"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"تغییر حساب"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"درحال دریافت همه عکسهای شما"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{به <xliff:g id="APP_NAME_0">^1</xliff:g> اجازه میدهید این فایل صوتی را تغییر دهد؟}one{به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> فایل صوتی را تغییر دهد؟}other{به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> فایل صوتی را تغییر دهد؟}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{درحال اصلاح فایل صوتی…}one{درحال اصلاح <xliff:g id="COUNT">^1</xliff:g> فایل صوتی…}other{درحال اصلاح <xliff:g id="COUNT">^1</xliff:g> فایل صوتی…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{به <xliff:g id="APP_NAME_0">^1</xliff:g> اجازه میدهید این ویدیو را تغییر دهد؟}one{به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> ویدیو را تغییر دهد؟}other{به <xliff:g id="APP_NAME_1">^1</xliff:g> اجازه میدهید <xliff:g id="COUNT">^2</xliff:g> ویدیو را تغییر دهد؟}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"محافظت امنیتی"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"هشدارهای تراتبدیل محلی"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"پیشرفت تراتبدیل محلی"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"بعداً دوباره امتحان کنید. عکسهایتان پساز رفع مشکل دردسترس خواهد بود."</string> + <string name="dialog_error_title" msgid="636349284077820636">"برخیاز عکسها را نمیتوان بار کرد"</string> + <string name="dialog_button_text" msgid="351366485240852280">"متوجهام"</string> </resources> diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml index e7f77c6b6..bfd4d84a7 100644 --- a/res/values-fi/strings.xml +++ b/res/values-fi/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Paikallinen tallennustila"</string> - <string name="app_label" msgid="9035307001052716210">"Median tallennustila"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Median valitsin"</string> <string name="artist_label" msgid="8105600993099120273">"Artisti"</string> <string name="unknown" msgid="2059049215682829375">"Tuntematon"</string> <string name="root_images" msgid="5861633549189045666">"Kuvat"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Pääsy pilvimediaan:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"–"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Pilvimediasovellusta ei voitu vaihtaa."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Median valitsin"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Median valitsin"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synkronoidaan mediaa…"</string> <string name="add" msgid="2894574044585549298">"Lisää"</string> <string name="deselect" msgid="4297825044827769490">"Poista valinta"</string> <string name="deselected" msgid="8488133193326208475">"Valinta poistettu"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ei albumeita"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Katso valitut"</string> <string name="picker_photos" msgid="7415035516411087392">"Kuvat"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumit"</string> <string name="picker_preview" msgid="6257414886055861039">"Esikatselu"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Siirry työprofiiliin"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> kohde}other{<xliff:g id="COUNT_1">^1</xliff:g> kohdetta}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Lisää (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Salli (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Älä salli mitään"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Lataukset"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Suosikit"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Videon toisto ei onnistu"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Tarkista internetyhteytesi ja yritä uudelleen"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Yritä uudelleen"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Pilvimediaa nyt saatavilla täältä: <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"ei valittu"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Valitsemaasi mediaa valmistellaan"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> valmiina"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Peruuta"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Varmuuskopioidut kuvat löytyvät nyt täältä"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Voit valita sovelluksen <xliff:g id="APP_NAME">%1$s</xliff:g> kuvat tililtä <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Tili päivitetty: <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Valitse sovellus"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Valitse tili"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Vaihda tiliä"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Ladataan kaikkia kuvia"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Saako <xliff:g id="APP_NAME_0">^1</xliff:g> muokata tätä audiotiedostoa?}other{Saako <xliff:g id="APP_NAME_1">^1</xliff:g> muokata <xliff:g id="COUNT">^2</xliff:g> audiotiedostoa?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Muokataan audiotiedostoa…}other{Muokataan <xliff:g id="COUNT">^1</xliff:g> audiotiedostoa…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Saako <xliff:g id="APP_NAME_0">^1</xliff:g> muokata tätä videota?}other{Saako <xliff:g id="APP_NAME_1">^1</xliff:g> muokata <xliff:g id="COUNT">^2</xliff:g> videota?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Turvallisuuden varmistaminen"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Natiivin transkoodin ilmoitukset"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Natiivin transkoodin edistyminen"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Yritä myöhemmin uudelleen. Kuvat ovat saatavilla, kun ongelma on korjattu."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Joitain kuvia ei voi ladata"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml index 72a801128..a35191636 100644 --- a/res/values-fr-rCA/strings.xml +++ b/res/values-fr-rCA/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimédia"</string> <string name="storage_description" msgid="4081716890357580107">"Stockage local"</string> - <string name="app_label" msgid="9035307001052716210">"Stockage multimédia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Contenu multimédia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Sélecteur d\'éléments multimédias"</string> <string name="artist_label" msgid="8105600993099120273">"Artiste"</string> <string name="unknown" msgid="2059049215682829375">"Inconnu"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -40,12 +39,15 @@ <string name="deny" msgid="6040983710442068936">"Refuser"</string> <string name="picker_browse" msgid="5554477454636075934">"Parcourir…"</string> <string name="picker_settings" msgid="6443463167344790260">"Appli multimédia infonuagique"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Application multimédia infonuagique"</string> - <string name="picker_settings_title" msgid="5647700706470673258">"Application multimédia infonuagique"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Appli multimédia infonuagique"</string> + <string name="picker_settings_title" msgid="5647700706470673258">"Appli multimédia infonuagique"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Accédez à votre contenu multimédia infonuagique lorsqu\'une application ou un site Web vous demande de sélectionner des photos ou des vidéos"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Accéder au contenu multimédia infonuagique à partir de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Aucune"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Changement appli multimédia infonuagique imposs."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Sélecteur d\'éléments multimédias"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Sélecteur d\'éléments multimédias"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synchronisation du contenu multimédia en cours…"</string> <string name="add" msgid="2894574044585549298">"Ajouter"</string> <string name="deselect" msgid="4297825044827769490">"Désélectionner"</string> <string name="deselected" msgid="8488133193326208475">"Désélectionné"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Aucun album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Afficher la sélection"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Aperçu"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Passez au profil professionnel"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> élément}one{<xliff:g id="COUNT_1">^1</xliff:g> élément}many{<xliff:g id="COUNT_1">^1</xliff:g> éléments}other{<xliff:g id="COUNT_1">^1</xliff:g> éléments}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Ajouter (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Autoriser (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ne rien autoriser"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Appareil photo"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Téléchargements"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoris"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Difficulté à lire la vidéo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Vérifiez votre connexion Internet et réessayez"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Réessayer"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Le contenu multimédia dans le nuage est maintenant offert par <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"non sélectionné"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Préparation du contenu multimédia sélectionné en cours…"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> sur <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> prêt(s)"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Annuler"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Les photos sauvegardées sont maintenant incluses"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Vous pouvez sélectionner des photos du compte <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> de <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Compte de <xliff:g id="APP_NAME">%1$s</xliff:g> mis à jour"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Choisir une application"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Choisir un compte"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Changer de compte"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Chargement de vos photos en cours…"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Autoriser <xliff:g id="APP_NAME_0">^1</xliff:g> à modifier ce fichier audio?}one{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichier audio?}many{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio?}other{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modification du fichier audio en cours…}one{Modification de <xliff:g id="COUNT">^1</xliff:g> fichier audio en cours…}many{Modification de <xliff:g id="COUNT">^1</xliff:g> fichiers audio en cours…}other{Modification de <xliff:g id="COUNT">^1</xliff:g> fichiers audio en cours…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Autoriser <xliff:g id="APP_NAME_0">^1</xliff:g> à modifier cette vidéo?}one{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéo?}many{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéos?}other{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protection de sécurité"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertes de transcodage natif"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progression du transcodage natif"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Réessayez plus tard. Vos photos seront accessibles dès que le problème sera résolu."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Impossible de charger certaines photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml index 7c8edae39..131c795db 100644 --- a/res/values-fr/strings.xml +++ b/res/values-fr/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimédia"</string> <string name="storage_description" msgid="4081716890357580107">"Stockage local"</string> - <string name="app_label" msgid="9035307001052716210">"Stockage multimédia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimédia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Sélecteur de fichiers multimédias"</string> <string name="artist_label" msgid="8105600993099120273">"Artiste"</string> <string name="unknown" msgid="2059049215682829375">"Inconnu"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Accéder aux contenus multimédias cloud à partir de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Aucune"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Impossible de changer l\'appli multimédia cloud."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Sélecteur de fichiers multimédias"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Sélecteur de fichiers multimédias"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synchronisation des fichiers multimédias…"</string> <string name="add" msgid="2894574044585549298">"Ajouter"</string> <string name="deselect" msgid="4297825044827769490">"Désélectionner"</string> <string name="deselected" msgid="8488133193326208475">"Désélectionné"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Aucun album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Afficher la sélection"</string> <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Aperçu"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Passer au professionnel"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Passer au personnel"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Passer au profil professionnel"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Passer au profil personnel"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bloqué par votre administrateur"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Vous n\'êtes pas autorisé à accéder à des données professionnelles depuis une appli personnelle"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Vous n\'êtes pas autorisé à accéder à des données à caractère personnel depuis une appli professionnelle"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> élément}one{<xliff:g id="COUNT_1">^1</xliff:g> élément}many{<xliff:g id="COUNT_1">^1</xliff:g> éléments}other{<xliff:g id="COUNT_1">^1</xliff:g> éléments}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Ajouter (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Autoriser (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ne rien autoriser"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Appareil photo"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Téléchargements"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoris"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problème de lecture vidéo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Vérifiez votre connexion Internet, puis réessayez"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Réessayer"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Fichier multimédia cloud désormais disponible depuis <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"non sélectionné"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Préparation des fichiers multimédias que vous avez sélectionnés"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Prêt(s) : <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> sur <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Annuler"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Les photos sauvegardées sont désormais incluses"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Vous pouvez sélectionner des photos de <xliff:g id="APP_NAME">%1$s</xliff:g>, compte <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Vous pouvez sélectionner des photos issues du compte <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> dans l\'appli <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Compte <xliff:g id="APP_NAME">%1$s</xliff:g> mis à jour"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Les photos de <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> sont désormais incluses ici"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Sélectionner une appli multimédia cloud"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Sélectionner une appli"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Sélectionner un compte"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Changer de compte"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Chargement de toutes vos photos…"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Autoriser <xliff:g id="APP_NAME_0">^1</xliff:g> à modifier ce fichier audio ?}one{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichier audio ?}many{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio ?}other{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> fichiers audio ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modification du fichier audio…}one{Modification de <xliff:g id="COUNT">^1</xliff:g> fichier audio…}many{Modification de <xliff:g id="COUNT">^1</xliff:g> fichiers audio…}other{Modification de <xliff:g id="COUNT">^1</xliff:g> fichiers audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Autoriser <xliff:g id="APP_NAME_0">^1</xliff:g> à modifier cette vidéo ?}one{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéo ?}many{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéos ?}other{Autoriser <xliff:g id="APP_NAME_1">^1</xliff:g> à modifier <xliff:g id="COUNT">^2</xliff:g> vidéos ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protection de sécurité"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertes de transcodage natif"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progression du transcodage natif"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Réessayez plus tard. Vos photos seront disponibles une fois le problème résolu."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Impossible de charger certaines photos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-gl/strings.xml b/res/values-gl/strings.xml index bd03ef3a0..b0fda91a9 100644 --- a/res/values-gl/strings.xml +++ b/res/values-gl/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia"</string> <string name="storage_description" msgid="4081716890357580107">"Almacenamento local"</string> - <string name="app_label" msgid="9035307001052716210">"Almacenamento multimedia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Contido multimedia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Seleccionador multimedia"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Descoñecida"</string> <string name="root_images" msgid="5861633549189045666">"Imaxes"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Acceder ao contido multimedia gardado na nube desde"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ningunha"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"App multimedia con servizo na nube non cambiada."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Seleccionador de contido multimedia"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Seleccionador de contido multimedia"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizando contido multimedia…"</string> <string name="add" msgid="2894574044585549298">"Engadir"</string> <string name="deselect" msgid="4297825044827769490">"Anular selección"</string> <string name="deselected" msgid="8488133193326208475">"Anulouse a selección"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Non hai álbums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ver selección"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbums"</string> <string name="picker_preview" msgid="6257414886055861039">"Vista previa"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Cambiar ao perfil de traballo"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elemento}other{<xliff:g id="COUNT_1">^1</xliff:g> elementos}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Engadir (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Non permitir ningunha"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Cámara"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Descargas"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problemas ao reproducir o vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Comproba a conexión a Internet e téntao de novo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Tentar de novo"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Agora podes acceder desde <xliff:g id="PKG_NAME">%1$s</xliff:g> ao contido multimedia gardado na nube"</string> <string name="not_selected" msgid="2244008151669896758">"elemento non seleccionado"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparando recursos seleccionados"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Elementos listos: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Agora inclúense as fotos con copia de seguranza"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Podes seleccionar fotos da seguinte conta de <xliff:g id="APP_NAME">%1$s</xliff:g>: <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Actualizouse a conta de <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Escoller aplicación"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Seleccionar conta"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Cambiar de conta"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Cargando todas as fotos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Queres permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este ficheiro de audio?}other{Queres permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> ficheiros de audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modificando 1 ficheiro de audio…}other{Modificando <xliff:g id="COUNT">^1</xliff:g> ficheiros de audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Queres permitir que <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este vídeo?}other{Queres permitir que <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> vídeos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protección de seguranza"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertas de transcodificación nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progreso da transcodificación nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Téntao de novo máis tarde. As túas fotos estarán dispoñibles en canto se resolva o problema."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Non se poden cargar algunhas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entendido"</string> </resources> diff --git a/res/values-gu/strings.xml b/res/values-gu/strings.xml index 57f780806..b46ad9683 100644 --- a/res/values-gu/strings.xml +++ b/res/values-gu/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"મીડિયા"</string> <string name="storage_description" msgid="4081716890357580107">"સ્થાનિક સ્ટોરેજ"</string> - <string name="app_label" msgid="9035307001052716210">"મીડિયા સ્ટોરેજ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"મીડિયા"</string> + <string name="picker_app_label" msgid="1195424381053599122">"મીડિયા પિકર"</string> <string name="artist_label" msgid="8105600993099120273">"કલાકાર"</string> <string name="unknown" msgid="2059049215682829375">"અજાણ"</string> <string name="root_images" msgid="5861633549189045666">"છબીઓ"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"આમાંથી ક્લાઉડ મીડિયાને ઍક્સેસ કરો"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"એકપણ નહીં"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"આ સમયે ક્લાઉડ મીડિયા ઍપને બદલી શકાઈ નથી."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"મીડિયા પિકર"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"મીડિયા પિકર"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"મીડિયા સિંક કરી રહ્યાં છે…"</string> <string name="add" msgid="2894574044585549298">"ઉમેરો"</string> <string name="deselect" msgid="4297825044827769490">"નાપસંદ કરો"</string> <string name="deselected" msgid="8488133193326208475">"નાપસંદ કર્યું"</string> @@ -58,11 +60,13 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"કોઈ આલ્બમ નથી"</string> <string name="picker_view_selected" msgid="2266031384396143883">"પસંદ કરેલા ફોટા જુઓ"</string> <string name="picker_photos" msgid="7415035516411087392">"ફોટા"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"આલ્બમ"</string> <string name="picker_preview" msgid="6257414886055861039">"પ્રીવ્યૂ કરો"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ઑફિસની પ્રોફાઇલ પર સ્વિચ કરો"</string> <string name="picker_personal_profile" msgid="639484258397758406">"વ્યક્તિગત પ્રોફાઇલ પર સ્વિચ કરો"</string> - <string name="picker_profile_admin_title" msgid="4172022376418293777">"તમારા વ્યવસ્થાપકે સુવિધા બ્લૉક કરી છે"</string> + <string name="picker_profile_admin_title" msgid="4172022376418293777">"તમારા ઍડમિને સુવિધા બ્લૉક કરી છે"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"વ્યક્તિગત ઍપ પરથી ઑફિસનો ડેટા ઍક્સેસ કરવાની પરવાનગી નથી"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"ઑફિસ માટેની ઍપ પરથી વ્યક્તિગત ડેટા ઍક્સેસ કરવાની પરવાનગી નથી"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"ઑફિસ માટેની ઍપ થોભાવવામાં આવી છે"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> આઇટમ}one{<xliff:g id="COUNT_1">^1</xliff:g> આઇટમ}other{<xliff:g id="COUNT_1">^1</xliff:g> આઇટમ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"ઉમેરો (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"મંજૂરી આપો (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"કોઈને મંજૂરી આપશો નહીં"</string> <string name="picker_category_camera" msgid="4857367052026843664">"કૅમેરા"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ડાઉનલોડ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"મનપસંદ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"વીડિયો ચલાવવામાં સમસ્યા આવી"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"તમારું ઇન્ટરનેટ કનેક્શન ચેક કરો અને ફરી પ્રયાસ કરો"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ફરી પ્રયાસ કરો"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ક્લાઉડ મીડિયા હવે <xliff:g id="PKG_NAME">%1$s</xliff:g>માંથી પણ ઉપલબ્ધ છે"</string> <string name="not_selected" msgid="2244008151669896758">"પસંદ નહીં કરેલી"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"તમે પસંદ કરેલું મીડિયા તૈયાર કરી રહ્યાં છીએ"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>માંથી <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> તૈયાર"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"રદ કરો"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"બૅકઅપ લીધેલા ફોટા હવે શામેલ કરવામાં આવ્યા છે"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"તમે <xliff:g id="APP_NAME">%1$s</xliff:g> એકાઉન્ટના <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> પરથી ફોટા પસંદ કરી શકો છો"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> એકાઉન્ટ અપડેટ કરવામાં આવ્યું"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ઍપ પસંદ કરો"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"એકાઉન્ટ પસંદ કરો"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"એકાઉન્ટ બદલો"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"તમારા બધા ફોટા મેળવવામાં આવી રહ્યાં છે"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>ને આ ઑડિયો ફાઇલમાં ફેરફાર કરવાની મંજૂરી આપીએ?}one{<xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરવાની મંજૂરી આપીએ?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરવાની મંજૂરી આપીએ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ઑડિયો ફાઇલમાં ફેરફાર કરી રહ્યાં છીએ…}one{<xliff:g id="COUNT">^1</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરી રહ્યાં છીએ…}other{<xliff:g id="COUNT">^1</xliff:g> ઑડિયો ફાઇલમાં ફેરફાર કરી રહ્યાં છીએ…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>ને આ વીડિયોમાં ફેરફાર કરવાની મંજૂરી આપીએ?}one{<xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> વીડિયોમાં ફેરફાર કરવાની મંજૂરી આપીએ?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>ને <xliff:g id="COUNT">^2</xliff:g> વીડિયોમાં ફેરફાર કરવાની મંજૂરી આપીએ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"સલામતી સંરક્ષણ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"થોડા સમય પછી ફરી પ્રયાસ કરો. એકવાર સમસ્યાનું નિરાકરણ થઈ જાય, તે પછી તમારા ફોટા ઉપલબ્ધ થશે."</string> + <string name="dialog_error_title" msgid="636349284077820636">"અમુક ફોટા લોડ કરી શકાતા નથી"</string> + <string name="dialog_button_text" msgid="351366485240852280">"સમજાઈ ગયું"</string> </resources> diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml index e99c1b3cf..a493629b7 100644 --- a/res/values-hi/strings.xml +++ b/res/values-hi/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"मीडिया"</string> <string name="storage_description" msgid="4081716890357580107">"स्थानीय जगह"</string> - <string name="app_label" msgid="9035307001052716210">"मीडिया मेमोरी"</string> - <string name="picker_app_label" msgid="4254039089502164761">"मीडिया"</string> + <string name="picker_app_label" msgid="1195424381053599122">"मीडिया पिकर"</string> <string name="artist_label" msgid="8105600993099120273">"कलाकार"</string> <string name="unknown" msgid="2059049215682829375">"अज्ञात"</string> <string name="root_images" msgid="5861633549189045666">"इमेज"</string> @@ -46,9 +45,12 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"क्लाउड पर मौजूद मीडिया को यहां से ऐक्सेस करें:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"कोई नहीं"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"इस समय क्लाउड मीडिया ऐप्लिकेशन नहीं बदला जा सका."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"मीडिया पिकर"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"मीडिया पिकर"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"मीडिया को सिंक किया जा रहा है…"</string> <string name="add" msgid="2894574044585549298">"जोड़ें"</string> - <string name="deselect" msgid="4297825044827769490">"चुना हुआ हटाएं"</string> - <string name="deselected" msgid="8488133193326208475">"चुना हुआ हटाया गया"</string> + <string name="deselect" msgid="4297825044827769490">"चुने हुए का निशान हटाएं"</string> + <string name="deselected" msgid="8488133193326208475">"चुने हुए का निशान हटाया गया"</string> <string name="select" msgid="2704765470563027689">"चुनें"</string> <string name="selected" msgid="9151797369975828124">"चुना गया"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{ज़्यादा से ज़्यादा <xliff:g id="COUNT_0">^1</xliff:g> आइटम चुनें}one{ज़्यादा से ज़्यादा <xliff:g id="COUNT_1">^1</xliff:g> आइटम चुनें}other{ज़्यादा से ज़्यादा <xliff:g id="COUNT_1">^1</xliff:g> आइटम चुनें}}"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"कोई एल्बम नहीं है"</string> <string name="picker_view_selected" msgid="2266031384396143883">"चुनी गई फ़ोटो या वीडियो देखें"</string> <string name="picker_photos" msgid="7415035516411087392">"फ़ोटो"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"एल्बम"</string> <string name="picker_preview" msgid="6257414886055861039">"झलक"</string> <string name="picker_work_profile" msgid="2083221066869141576">"वर्क प्रोफ़ाइल पर जाएं"</string> @@ -67,13 +71,14 @@ <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"निजी डेटा को ऑफ़िस के काम से जुड़े ऐप्लिकेशन से ऐक्सेस करने की अनुमति नहीं है"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"वर्क ऐप्लिकेशन रोक दिए गए हैं"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"वर्क फ़ोटो देखने के लिए, ऑफ़िस के काम से जुड़े ऐप्लिकेशन चालू करें और दोबारा कोशिश करें"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"इस ऐप्लिकेशन के पास आपकी उन फ़ोटो का ही ऐक्सेस होता है जिन्हें आपने चुना हो"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"इस ऐप्लिकेशन के पास आपकी उन फ़ोटो का ही ऐक्सेस होता है जिन्हें आपने चुना है"</string> <string name="picker_header_permissions" msgid="675872774407768495">"वे फ़ोटो और वीडियो चुनें जिनका ऐक्सेस इस ऐप्लिकेशन को देना है"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> आइटम}one{<xliff:g id="COUNT_1">^1</xliff:g> आइटम}other{<xliff:g id="COUNT_1">^1</xliff:g> आइटम}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) जोड़ें"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"अनुमति दें (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"कोई फ़ोटो न चुनें"</string> <string name="picker_category_camera" msgid="4857367052026843664">"कैमरा"</string> - <string name="picker_category_downloads" msgid="793866660287361900">"डाउनलोड की गई चीज़ें"</string> + <string name="picker_category_downloads" msgid="793866660287361900">"डाउनलोड किए गए आइटम"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"पसंदीदा"</string> <string name="picker_category_screenshots" msgid="7216102327587644284">"स्क्रीनशॉट"</string> <!-- no translation found for picker_category_videos (1478458836380241356) --> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"वीडियो चलाने में समस्या हो रही है"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"अपने इंटरनेट कनेक्शन की जांच करें और फिर से कोशिश करें"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"फिर से कोशिश करें"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"क्लाउड मीडिया अब <xliff:g id="PKG_NAME">%1$s</xliff:g> पर उपलब्ध है"</string> <string name="not_selected" msgid="2244008151669896758">"नहीं चुना गया"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"आपकी चुनी गई मीडिया तैयार की जा रही है"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> में से <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> तैयार हैं"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"रद्द करें"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"बैक अप ली गई फ़ोटो अब जोड़ दी गई हैं"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"आपके पास, <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> वाले <xliff:g id="APP_NAME">%1$s</xliff:g> खाते से फ़ोटो चुनने का विकल्प है"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> खाता अपडेट किया गया"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"सुरक्षा के लिए बचाव"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"नेटिव ट्रांसकोड सूचना"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"नेटिव ट्रांसकोड स्थिति"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"कुछ देर बाद कोशिश करें. समस्या हल होते ही आपकी फ़ोटो उपलब्ध हो जाएंगी."</string> + <string name="dialog_error_title" msgid="636349284077820636">"कुछ फ़ोटो लोड नहीं की जा सकीं"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ठीक है"</string> </resources> diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml index d7014e8b0..c989254be 100644 --- a/res/values-hr/strings.xml +++ b/res/values-hr/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Mediji"</string> <string name="storage_description" msgid="4081716890357580107">"Lokalna pohrana"</string> - <string name="app_label" msgid="9035307001052716210">"Pohranjivanje na mediju"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Mediji"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Alat za izbor medija"</string> <string name="artist_label" msgid="8105600993099120273">"Izvođač"</string> <string name="unknown" msgid="2059049215682829375">"Nepoznato"</string> <string name="root_images" msgid="5861633549189045666">"Slike"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Pristupi medijima u oblaku putem"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ništa"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Aplikaciju za medijske sadržaje u oblaku trenutačno nije moguće promijeniti."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Alat za izbor medija"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Alat za izbor medija"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sinkroniziranje medija…"</string> <string name="add" msgid="2894574044585549298">"Dodaj"</string> <string name="deselect" msgid="4297825044827769490">"Poništi odabir"</string> <string name="deselected" msgid="8488133193326208475">"Odabir poništen"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nema albuma"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži odabrano"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotografije"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumi"</string> <string name="picker_preview" msgid="6257414886055861039">"Pregled"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Prijeđite na poslovni"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> stavka}one{<xliff:g id="COUNT_1">^1</xliff:g> stavka}few{<xliff:g id="COUNT_1">^1</xliff:g> stavke}other{<xliff:g id="COUNT_1">^1</xliff:g> stavki}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Dodaj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Dopusti (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nemoj dopustiti prijenos"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Preuzimanja"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Omiljeno"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Poteškoće s reprodukcijom videozapisa"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Provjerite internetsku vezu i pokušajte ponovo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Pokušaj ponovo"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Medijski sadržaj u oblaku sada je dostupan iz aplikacije <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nije odabrano"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Priprema odabranih medija"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Spremno: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> od <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Odustani"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Sad su uključene sigurnosno kopirane fotografije"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Možete odabrati aplikacije s računa <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> za aplikaciju <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Ažuriran je račun za aplikaciju <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Odaberite aplikaciju"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Odaberite račun"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Promijenite račun"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Dohvaćanje svih vaših fotografija"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_0">^1</xliff:g> da izmijeni tu audiodatoteku?}one{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> audiodatoteku?}few{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> audiodatoteke?}other{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> audiodatoteka?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Mijenjanje audiodatoteke…}one{Mijenjanje <xliff:g id="COUNT">^1</xliff:g> audiodatoteke…}few{Mijenjanje <xliff:g id="COUNT">^1</xliff:g> audiodatoteke…}other{Mijenjanje <xliff:g id="COUNT">^1</xliff:g> audiodatoteka…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_0">^1</xliff:g> da izmijeni taj videozapis?}one{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> videozapis?}few{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> videozapisa?}other{Želite li dopustiti aplikaciji <xliff:g id="APP_NAME_1">^1</xliff:g> da izmijeni <xliff:g id="COUNT">^2</xliff:g> videozapisa?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Osiguranje"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Upozorenja nativnog konvertiranja"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Napredak nativnog konvertiranja"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Pokušajte ponovo poslije. Vaše fotografije bit će dostupne kad se problem riješi."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Neke fotografije ne mogu se učitati"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Shvaćam"</string> </resources> diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml index bbf77bef0..c5d7870ec 100644 --- a/res/values-hu/strings.xml +++ b/res/values-hu/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Média"</string> <string name="storage_description" msgid="4081716890357580107">"Helyi tárhely"</string> - <string name="app_label" msgid="9035307001052716210">"Médiatároló"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Média"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Médiaválasztó"</string> <string name="artist_label" msgid="8105600993099120273">"Előadó"</string> <string name="unknown" msgid="2059049215682829375">"Ismeretlen"</string> <string name="root_images" msgid="5861633549189045666">"Képek"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Hozzáférés a következő szolgáltatásban tárolt felhőbeli médiatartalmakhoz:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nincs"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Most nem módosítható a felhőbeli médiaalkalmazás."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Médiaválasztó"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Médiaválasztó"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Médiatartalom szinkronizálása…"</string> <string name="add" msgid="2894574044585549298">"Hozzáadás"</string> <string name="deselect" msgid="4297825044827769490">"Jelölés törlése"</string> <string name="deselected" msgid="8488133193326208475">"Kijelölés megszüntetve"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nincsenek albumok"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Kijelöltek megnézése"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotók"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumok"</string> <string name="picker_preview" msgid="6257414886055861039">"Előnézet"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Átváltás munkaprofilra"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elem}other{<xliff:g id="COUNT_1">^1</xliff:g> elem}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Hozzáadás (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Engedélyezés (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Egy se legyen engedélyezve"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Letöltések"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Kedvencek"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Probléma merült fel a videó lejátszásakor"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Ellenőrizze internetkapcsolatát, és próbálkozzon újra"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Újra"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"A felhőbeli médiatartalmak már hozzáférhetők a következőből: <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nincs kiválasztva"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"A kiválasztott média előkészítése…"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>/<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> kész"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Mégse"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Mostantól rendelkezésre állnak a fotók, amelyekről biztonsági másolat készült"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Kiválaszthat fotókat a(z) <xliff:g id="APP_NAME">%1$s</xliff:g> alkalmazásból (<xliff:g id="USER_ACCOUNT">%2$s</xliff:g>-fiók)"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-fiók frissítve"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Válasszon alkalmazást"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Fiók kiválasztása"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Másik fiók választása"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Folyamatban van az összes fotó lekérése"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{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?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Az audiofájl módosítása folyamatban van…}other{<xliff:g id="COUNT">^1</xliff:g> audiofájl módosítása folyamatban van…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Engedélyezi a(z) <xliff:g id="APP_NAME_0">^1</xliff:g> számára ennek a videónak a módosítását?}other{Engedélyezi a(z) <xliff:g id="APP_NAME_1">^1</xliff:g> számára <xliff:g id="COUNT">^2</xliff:g> videó módosítását?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Biztonsági védelem"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Natív átkódolási értesítések"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Natív átkódolási folyamat"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Próbálkozzon újra később. Fotói hozzáférhetők lesznek a probléma elhárítását követően."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Egyes fotók nem tölthetők be"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Értem"</string> </resources> diff --git a/res/values-hy/strings.xml b/res/values-hy/strings.xml index 40268d95c..6fee32e01 100644 --- a/res/values-hy/strings.xml +++ b/res/values-hy/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Մեդիա"</string> <string name="storage_description" msgid="4081716890357580107">"Սարքի հիշողություն"</string> - <string name="app_label" msgid="9035307001052716210">"Մեդիա կրիչ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Մեդիա"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Մուլտիմեդիա ընտրիչ"</string> <string name="artist_label" msgid="8105600993099120273">"Կատարող"</string> <string name="unknown" msgid="2059049215682829375">"Անհայտ"</string> <string name="root_images" msgid="5861633549189045666">"Պատկերներ"</string> @@ -46,9 +45,12 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Օգտվեք ամպային մեդիա բովանդակությունից հետևյալ մատակարարից՝"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Չկա"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Չհաջողվեց փոխել ամպային մուլտիմեդիա հավելվածը։"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Մուլտիմեդիա ընտրիչ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Մուլտիմեդիա ընտրիչ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Մեդիաֆայլերը համաժամացվում են…"</string> <string name="add" msgid="2894574044585549298">"Ավելացնել"</string> - <string name="deselect" msgid="4297825044827769490">"Ապընտրել"</string> - <string name="deselected" msgid="8488133193326208475">"Ապընտրված"</string> + <string name="deselect" msgid="4297825044827769490">"Չեղարկել ընտրությունը"</string> + <string name="deselected" msgid="8488133193326208475">"Ընտրությունը չեղարկված է"</string> <string name="select" msgid="2704765470563027689">"Ընտրել"</string> <string name="selected" msgid="9151797369975828124">"Ընտրված"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Ընտրեք մինչև <xliff:g id="COUNT_0">^1</xliff:g> տարր}one{Ընտրեք մինչև <xliff:g id="COUNT_1">^1</xliff:g> տարր}other{Ընտրեք մինչև <xliff:g id="COUNT_1">^1</xliff:g> տարր}}"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ալբոմներ չկան"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Դիտել ընտրվածը"</string> <string name="picker_photos" msgid="7415035516411087392">"Լուսանկարներ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Ալբոմներ"</string> <string name="picker_preview" msgid="6257414886055861039">"Նախադիտում"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Բացել աշխատանքային պրոֆիլը"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> տարր}one{<xliff:g id="COUNT_1">^1</xliff:g> տարր}other{<xliff:g id="COUNT_1">^1</xliff:g> տարր}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Ավելացնել (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Թույլատրել (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Արգելել բոլորը"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Տեսախցիկ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Ներբեռնումներ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Ընտրանի"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Տեսանյութի նվագարկման սխալ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Ստուգեք ձեր ինտերնետ կապը և նորից փորձեք"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Նորից փորձել"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Ամպային մեդիա բովանդակությունն այժմ հասանելի է <xliff:g id="PKG_NAME">%1$s</xliff:g> հավելվածից"</string> <string name="not_selected" msgid="2244008151669896758">"ընտրված չէ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Ձեր ընտրած մեդիաֆայլերի նախապատրաստում"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> պատրաստ է"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Չեղարկել"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Պահուստավորված լուսանկարներն այժմ ավելացված են"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Դուք կարող եք լուսանկարներ ընտրել «<xliff:g id="APP_NAME">%1$s</xliff:g>» հավելվածի <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> հաշվից"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> հավելվածի հաշիվը թարմացվեց"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Ընտրել հավելված"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Ընտրել հաշիվը"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Փոխել հաշիվը"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Ձեր բոլոր լուսանկարները բեռնվում են"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Թույլատրե՞լ <xliff:g id="APP_NAME_0">^1</xliff:g> հավելվածին վերականգնել այս աուդիո ֆայլն աղբարկղից}one{Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին վերականգնել <xliff:g id="COUNT">^2</xliff:g> աուդիո ֆայլ աղբարկղից}other{Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին վերականգնել <xliff:g id="COUNT">^2</xliff:g> աուդիո ֆայլ աղբարկղից}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Աուդիո ֆայլը փոփոխվում է…}one{<xliff:g id="COUNT">^1</xliff:g> աուդիո ֆայլ փոփոխվում է…}other{<xliff:g id="COUNT">^1</xliff:g> աուդիո ֆայլ փոփոխվում է…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Թույլատրե՞լ <xliff:g id="APP_NAME_0">^1</xliff:g> հավելվածին փոփոխել այս տեսանյութը}one{Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին փոփոխել <xliff:g id="COUNT">^2</xliff:g> տեսանյութ}other{Թույլատրե՞լ <xliff:g id="APP_NAME_1">^1</xliff:g> հավելվածին փոփոխել <xliff:g id="COUNT">^2</xliff:g> տեսանյութ}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Անվտանգության պաշտպանություն"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Տրանսկոդավորման մասին հիմնական ծանուցումներ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Տրանսկոդավորման հիմնական գործընթաց"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Փորձեք ավելի ուշ։ Ձեր լուսանկարները հասանելի կլինեն, երբ խնդիրը լուծվի։"</string> + <string name="dialog_error_title" msgid="636349284077820636">"Չհաջողվեց բեռնել որոշ լուսանկարներ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Եղավ"</string> </resources> diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml index 778892ba1..e655fcbf6 100644 --- a/res/values-in/strings.xml +++ b/res/values-in/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Penyimpanan lokal"</string> - <string name="app_label" msgid="9035307001052716210">"Penyimpanan Media"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Pemilih media"</string> <string name="artist_label" msgid="8105600993099120273">"Artis"</string> <string name="unknown" msgid="2059049215682829375">"Tidak diketahui"</string> <string name="root_images" msgid="5861633549189045666">"Gambar"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Akses media cloud dari"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Tidak ada"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Saat ini tidak bisa mengubah aplikasi media cloud."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Pemilih media"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Pemilih media"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Menyinkronkan media…"</string> <string name="add" msgid="2894574044585549298">"Tambahkan"</string> <string name="deselect" msgid="4297825044827769490">"Batalkan pilihan"</string> <string name="deselected" msgid="8488133193326208475">"Batal dipilih"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Tidak ada album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Lihat yang dipilih"</string> <string name="picker_photos" msgid="7415035516411087392">"Foto"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Pratinjau"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Beralih ke profil kerja"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> item}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Tambahkan (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Izinkan (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Tidak ada"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Hasil download"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favorit"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Terjadi masalah saat memutar video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Periksa koneksi internet Anda, lalu coba lagi"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Coba lagi"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Media cloud kini tersedia dari <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"tidak dipilih"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Menyiapkan media yang dipilih"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> dari <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> siap"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Batal"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Foto yang dicadangkan kini disertakan"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Anda dapat memilih foto dari akun <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Akun <xliff:g id="APP_NAME">%1$s</xliff:g> diperbarui"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Pilih aplikasi"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Pilih akun"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Ubah akun"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Mengambil semua foto Anda"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Izinkan <xliff:g id="APP_NAME_0">^1</xliff:g> mengubah file audio ini?}other{Izinkan <xliff:g id="APP_NAME_1">^1</xliff:g> mengubah <xliff:g id="COUNT">^2</xliff:g> file audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Mengubah file audio …}other{Mengubah <xliff:g id="COUNT">^1</xliff:g> file audio …}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Izinkan <xliff:g id="APP_NAME_0">^1</xliff:g> mengubah video ini?}other{Izinkan <xliff:g id="APP_NAME_1">^1</xliff:g> mengubah <xliff:g id="COUNT">^2</xliff:g> video?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Perlindungan keselamatan"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Peringatan Transcoding Native"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progres Transcoding Native"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Coba lagi nanti. Foto Anda akan tersedia setelah masalah diselesaikan."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Tidak dapat memuat beberapa Foto"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Oke"</string> </resources> diff --git a/res/values-is/strings.xml b/res/values-is/strings.xml index 9a90ead68..02ea74b16 100644 --- a/res/values-is/strings.xml +++ b/res/values-is/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Margmiðlun"</string> <string name="storage_description" msgid="4081716890357580107">"Staðbundin vistun"</string> - <string name="app_label" msgid="9035307001052716210">"Efnisgeymsla"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Efni"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Efnisval"</string> <string name="artist_label" msgid="8105600993099120273">"Flytjandi"</string> <string name="unknown" msgid="2059049215682829375">"Óþekkt"</string> <string name="root_images" msgid="5861633549189045666">"Myndir"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Opna skýjaefni frá"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ekkert"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Ekki tókst að breyta efnisforriti í skýi."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Efnisval"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Efnisval"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Samstillir efni…"</string> <string name="add" msgid="2894574044585549298">"Bæta við"</string> <string name="deselect" msgid="4297825044827769490">"Afvelja"</string> <string name="deselected" msgid="8488133193326208475">"Afvalið"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Engin albúm"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Skoða valið"</string> <string name="picker_photos" msgid="7415035516411087392">"Myndir"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albúm"</string> <string name="picker_preview" msgid="6257414886055861039">"Forskoða"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Skipta yfir í vinnusnið"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Skipta yfir í eigið snið"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Skipta yfir í einkasnið"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Útilokað af kerfisstjóra"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Óheimilt er að opna vinnugögn í forriti til einkanota"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Óheimilt er að opna einkagögn í vinnuforriti"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> atriði}one{<xliff:g id="COUNT_1">^1</xliff:g> atriði}other{<xliff:g id="COUNT_1">^1</xliff:g> atriði}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Bæta við (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Leyfa (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ekki leyfa neitt"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Myndavél"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Niðurhal"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Uppáhald"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Vandamál við spilun myndskeiðs"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Athugaðu nettenginguna og reyndu aftur"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Reyna aftur"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Skýjaefni er nú í boði frá <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"ekki valið"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Undirbýr valið efni"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> af <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> til reiðu"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Hætta við"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Afritaðar myndir eru nú hafðar með"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Þú getur valið myndir af reikningnum <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> í: <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>: reikningur uppfærður"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Velja forrit"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Veldu reikning"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Skipta um reikning"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Sækir allar myndirnar þínar"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Leyfa <xliff:g id="APP_NAME_0">^1</xliff:g> að breyta þessari hljóðskrá?}one{Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> hljóðskrá?}other{Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> hljóðskrám?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Breytir hljóðskrá…}one{Breytir <xliff:g id="COUNT">^1</xliff:g> hljóðskrá…}other{Breytir <xliff:g id="COUNT">^1</xliff:g> hljóðskrám…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Leyfa <xliff:g id="APP_NAME_0">^1</xliff:g> að breyta þessu myndskeiði?}one{Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> myndskeiði?}other{Leyfa <xliff:g id="APP_NAME_1">^1</xliff:g> að breyta <xliff:g id="COUNT">^2</xliff:g> myndskeiðum?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Öryggisbúnaður"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Viðvaranir fyrir sérforritaðar umkóðanir"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Sérforritað umkóðunarferli"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Reyndu aftur síðar. Myndirnar þínar verða tiltækar um leið og vandamálið er leyst."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Ekki tekst að hlaða sumum myndum"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Ég skil"</string> </resources> diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml index d0278be65..e86dc9704 100644 --- a/res/values-it/strings.xml +++ b/res/values-it/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Supporti multimediali"</string> <string name="storage_description" msgid="4081716890357580107">"Archiviazione locale"</string> - <string name="app_label" msgid="9035307001052716210">"Media Storage"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Contenuti multimediali"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Selettore media"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Sconosciuto"</string> <string name="root_images" msgid="5861633549189045666">"Immagini"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"App multimediale cloud"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"App multimediale cloud"</string> <string name="picker_settings_title" msgid="5647700706470673258">"App multimediale con funzionalità cloud"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Accedi ai tuoi contenuti multimediali cloud quando un\'app o un sito web ti chiede di selezionare foto o video"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Accedi ai tuoi contenuti multimediali sul cloud quando un\'app o un sito web ti chiede di selezionare foto o video"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Accedi ai contenuti multimediali sul cloud da"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nessuna"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Ora è impossibile cambiare app multimediale cloud."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Selettore media"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Selettore media"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizzazione dei media in corso…"</string> <string name="add" msgid="2894574044585549298">"Aggiungi"</string> <string name="deselect" msgid="4297825044827769490">"Deseleziona"</string> <string name="deselected" msgid="8488133193326208475">"Deselezionato"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nessun album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Visualizza selezione"</string> <string name="picker_photos" msgid="7415035516411087392">"Foto"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Anteprima"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Passa al profilo di lavoro"</string> @@ -67,11 +71,12 @@ <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Non è consentito accedere ai dati personali da un\'app di lavoro"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Le app di lavoro sono in pausa"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"Per aprire le foto relative al lavoro, attiva le app di lavoro e riprova"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"Questa app può accedere soltanto alle foto selezionate da te"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"Questa app può accedere soltanto alle foto che selezioni"</string> <string name="picker_header_permissions" msgid="675872774407768495">"Seleziona le foto e i video a cui può accedere questa app"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elemento}many{<xliff:g id="COUNT_1">^1</xliff:g> elementi}other{<xliff:g id="COUNT_1">^1</xliff:g> elementi}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Aggiungi (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Consenti (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Non consentire foto"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Fotocamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Download"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Preferiti"</string> @@ -92,18 +97,19 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Errore durante la riproduzione del video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Controlla la connessione a Internet e riprova"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Riprova"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Contenuti multimediali salvati su cloud ora disponibili dall\'app <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"Elemento non selezionato"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparazione dei contenuti multimediali selezionati in corso…"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> su <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> pronti"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Annulla"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ora sono incluse le foto di cui hai eseguito il backup"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puoi selezionare foto dall\'account <xliff:g id="APP_NAME">%1$s</xliff:g> di <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puoi selezionare foto dall\'account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> nell\'app <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Account <xliff:g id="APP_NAME">%1$s</xliff:g> aggiornato"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Ora le foto di <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> sono incluse qui"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Scegli un\'app multimediale con funzionalità cloud"</string> <string name="picker_banner_cloud_choose_app_desc" msgid="2359212653555524926">"Per includere qui le foto di cui hai eseguito il backup, scegli un\'app multimediale con funzionalità cloud nelle impostazioni"</string> <string name="picker_banner_cloud_choose_account_title" msgid="5010901185639577685">"Scegli un account <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_choose_account_desc" msgid="8868134443673142712">"Per includere qui le foto di <xliff:g id="APP_NAME">%1$s</xliff:g>, scegli un account nell\'app"</string> - <string name="picker_banner_cloud_dismiss_button" msgid="2935903078288463882">"Ignora"</string> + <string name="picker_banner_cloud_dismiss_button" msgid="2935903078288463882">"Chiudi"</string> <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Scegli app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Scegli account"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Cambia account"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protezione di sicurezza"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Avvisi di transcodifica nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Avanzamento di transcodifica nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Riprova più tardi. Le tue foto saranno disponibili dopo aver risolto il problema."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Impossibile caricare alcune foto"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml index 62771db75..31c3197b9 100644 --- a/res/values-iw/strings.xml +++ b/res/values-iw/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"מדיה"</string> <string name="storage_description" msgid="4081716890357580107">"אחסון מקומי"</string> - <string name="app_label" msgid="9035307001052716210">"אחסון מדיה"</string> - <string name="picker_app_label" msgid="4254039089502164761">"מדיה"</string> + <string name="picker_app_label" msgid="1195424381053599122">"הכלי לבחירת מדיה"</string> <string name="artist_label" msgid="8105600993099120273">"אומן"</string> <string name="unknown" msgid="2059049215682829375">"לא ידוע"</string> <string name="root_images" msgid="5861633549189045666">"תמונות"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"גישה למדיה בענן מתוך"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ללא"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"לא ניתן להחליף את אפליקציית המדיה בענן כרגע."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"הכלי לבחירת מדיה"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"הכלי לבחירת מדיה"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"המדיה בתהליך סנכרון…"</string> <string name="add" msgid="2894574044585549298">"הוספה"</string> <string name="deselect" msgid="4297825044827769490">"ביטול הבחירה"</string> <string name="deselected" msgid="8488133193326208475">"הבחירה בוטלה"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"אין אלבומים"</string> <string name="picker_view_selected" msgid="2266031384396143883">"הצגת הפריטים שנבחרו"</string> <string name="picker_photos" msgid="7415035516411087392">"תמונות"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"אלבומים"</string> <string name="picker_preview" msgid="6257414886055861039">"תצוגה מקדימה"</string> <string name="picker_work_profile" msgid="2083221066869141576">"לפרופיל העבודה"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{פריט אחד (<xliff:g id="COUNT_0">^1</xliff:g>)}one{<xliff:g id="COUNT_1">^1</xliff:g> פריטים}two{<xliff:g id="COUNT_1">^1</xliff:g> פריטים}other{<xliff:g id="COUNT_1">^1</xliff:g> פריטים}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"הוספה (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"אישור (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"לא להוסיף"</string> <string name="picker_category_camera" msgid="4857367052026843664">"מצלמה"</string> <string name="picker_category_downloads" msgid="793866660287361900">"הורדות"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"מועדפים"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"בעיות בהפעלת הסרטון"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"מומלץ לבדוק את החיבור לאינטרנט ולנסות שוב"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ניסיון נוסף"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"מדיה בענן מתוך <xliff:g id="PKG_NAME">%1$s</xliff:g> זמינה עכשיו"</string> <string name="not_selected" msgid="2244008151669896758">"לא נבחר"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"המדיה שבחרת בתהליך הכנה"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> מתוך <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> מוכנים"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ביטול"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"התמונות שעברו גיבוי נכללות עכשיו"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ניתן לבחור תמונות מחשבון <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> באפליקציה <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"החשבון באפליקציה <xliff:g id="APP_NAME">%1$s</xliff:g> עודכן"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"בחירת אפליקציה"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"בחירת חשבון"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"החלפת חשבון"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"איסוף התמונות מתבצע"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{לאפשר לאפליקציה <xliff:g id="APP_NAME_0">^1</xliff:g> לשנות את קובץ האודיו הזה?}one{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> קובצי אודיו?}two{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> קובצי אודיו?}other{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> קובצי אודיו?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{מתבצע שינוי בקובץ האודיו…}one{מתבצע שינוי ב-<xliff:g id="COUNT">^1</xliff:g> קובצי אודיו…}two{מתבצע שינוי ב-<xliff:g id="COUNT">^1</xliff:g> קובצי אודיו…}other{מתבצע שינוי ב-<xliff:g id="COUNT">^1</xliff:g> קובצי אודיו…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{לאפשר לאפליקציה <xliff:g id="APP_NAME_0">^1</xliff:g> לשנות את הסרטון הזה?}one{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> סרטונים?}two{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> סרטונים?}other{לאפשר לאפליקציה <xliff:g id="APP_NAME_1">^1</xliff:g> לשנות <xliff:g id="COUNT">^2</xliff:g> סרטונים?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"הגנה על בטיחות"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"התראות של המרת קידוד מקורית"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"התקדמות של המרת קידוד מקורית"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"כדאי לנסות שוב אחר כך. התמונות שלך יהיו זמינות כשהבעיה תיפתר."</string> + <string name="dialog_error_title" msgid="636349284077820636">"יש תמונות שאי אפשר לטעון כרגע"</string> + <string name="dialog_button_text" msgid="351366485240852280">"הבנתי"</string> </resources> diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml index 11f78493e..30a9d9fb9 100644 --- a/res/values-ja/strings.xml +++ b/res/values-ja/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"メディア"</string> <string name="storage_description" msgid="4081716890357580107">"ローカル ストレージ"</string> - <string name="app_label" msgid="9035307001052716210">"メディア ストレージ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"メディア"</string> + <string name="picker_app_label" msgid="1195424381053599122">"メディアの選択"</string> <string name="artist_label" msgid="8105600993099120273">"アーティスト"</string> <string name="unknown" msgid="2059049215682829375">"不明"</string> <string name="root_images" msgid="5861633549189045666">"画像"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"クラウド メディアへのアクセス元"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"なし"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"クラウド メディアアプリを変更できませんでした。"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"メディアの選択"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"メディアの選択"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"メディアを同期しています…"</string> <string name="add" msgid="2894574044585549298">"追加"</string> <string name="deselect" msgid="4297825044827769490">"選択を解除"</string> <string name="deselected" msgid="8488133193326208475">"選択解除済み"</string> @@ -58,20 +60,23 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"アルバムはありません"</string> <string name="picker_view_selected" msgid="2266031384396143883">"選択した写真を見る"</string> <string name="picker_photos" msgid="7415035516411087392">"写真"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"アルバム"</string> <string name="picker_preview" msgid="6257414886055861039">"プレビュー"</string> <string name="picker_work_profile" msgid="2083221066869141576">"仕事用に切り替える"</string> <string name="picker_personal_profile" msgid="639484258397758406">"個人用に切り替える"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"管理者によりブロックされています"</string> - <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"個人用アプリから仕事用データにアクセスすることは認められていません"</string> + <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"個人用アプリから仕事用データにアクセスすることは許可されていません"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"仕事用アプリから個人データにアクセスすることは認められていません"</string> - <string name="picker_profile_work_paused_title" msgid="382212880704235925">"仕事用アプリは一時停止されています"</string> + <string name="picker_profile_work_paused_title" msgid="382212880704235925">"仕事用アプリ一時停止中"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"仕事用の写真を開くには、仕事用アプリを有効にしてからもう一度試してください。"</string> <string name="picker_privacy_message" msgid="9132700451027116817">"このアプリは、選択した写真にのみアクセスできます。"</string> <string name="picker_header_permissions" msgid="675872774407768495">"このアプリにアクセスを許可する写真と動画を選択してください"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> 件のアイテム}other{<xliff:g id="COUNT_1">^1</xliff:g> 件のアイテム}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"追加(<xliff:g id="COUNT">^1</xliff:g> 件)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"許可(<xliff:g id="COUNT">^1</xliff:g> 件)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"すべて許可しない"</string> <string name="picker_category_camera" msgid="4857367052026843664">"カメラ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ダウンロード"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"お気に入り"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"動画を再生できません"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"インターネット接続を確認してもう一度お試しください"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"再試行"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"<xliff:g id="PKG_NAME">%1$s</xliff:g> からクラウド メディアを利用できるようになりました"</string> <string name="not_selected" msgid="2244008151669896758">"未選択"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"選択したメディアの準備をする"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> 件準備完了"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"キャンセル"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"バックアップした写真が追加されました"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> のアカウント <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> の写真を選択できます"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> アカウントが更新されました"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"安全保護"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ネイティブ コード変換アラート"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ネイティブ コード変換進行状況"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"しばらくしてからもう一度お試しください。問題が解決されると、写真をご利用いただけるようになります。"</string> + <string name="dialog_error_title" msgid="636349284077820636">"読み込めなかった写真があります"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-ka/strings.xml b/res/values-ka/strings.xml index 58b1aee69..27398e9d9 100644 --- a/res/values-ka/strings.xml +++ b/res/values-ka/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"მედია"</string> <string name="storage_description" msgid="4081716890357580107">"ადგილობრივი მეხსიერება"</string> - <string name="app_label" msgid="9035307001052716210">"მედიის საცავი"</string> - <string name="picker_app_label" msgid="4254039089502164761">"მედია"</string> + <string name="picker_app_label" msgid="1195424381053599122">"მედიის ამომრჩევი"</string> <string name="artist_label" msgid="8105600993099120273">"შემსრულებელი"</string> <string name="unknown" msgid="2059049215682829375">"უცნობი"</string> <string name="root_images" msgid="5861633549189045666">"სურათები"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ღრუბლოვან მედიაზე წვდომა აქედან:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"არცერთი"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ღრუბლური მედია აპის შეცვლა ამჯერად ვერ ხერხდება."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"მედიის ამომრჩევი"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"მედიის ამომრჩევი"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"მიმდინარეობს მედიის სინქრონიზაცია…"</string> <string name="add" msgid="2894574044585549298">"დამატება"</string> <string name="deselect" msgid="4297825044827769490">"არჩევის გაუქმება"</string> <string name="deselected" msgid="8488133193326208475">"არჩევა გაუქმებულია"</string> @@ -58,9 +60,11 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ალბომები არ არის"</string> <string name="picker_view_selected" msgid="2266031384396143883">"არჩეულის ნახვა"</string> <string name="picker_photos" msgid="7415035516411087392">"ფოტოები"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ალბომები"</string> <string name="picker_preview" msgid="6257414886055861039">"გადახედვა"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"სამსახურის პროფილზე გადართვა"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"სამსახურზე გადართვა"</string> <string name="picker_personal_profile" msgid="639484258397758406">"პირად პროფილზე გადართვა"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"დაბლოკილია თქვენი ადმინისტრატორის მიერ"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"პირადი აპიდან სამუშაო სამსახურებრივ მონაცემებზე წვდომა დაუშვებელია"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ერთეული}other{<xliff:g id="COUNT_1">^1</xliff:g> ერთეული}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"დამატება (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"დაშვება (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"არცერთის დაშვება"</string> <string name="picker_category_camera" msgid="4857367052026843664">"კამერა"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ჩამოტვირთვები"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"რჩეულები"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ვიდეოს იკვრება პრობლემურად"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"შეამოწმეთ ინტერნეტთან კავშირი და სცადეთ ხელახლა"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ცდა"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ღრუბლოვანი მედია უკვე ხელმისაწვდომია <xliff:g id="PKG_NAME">%1$s</xliff:g>-ისგან"</string> <string name="not_selected" msgid="2244008151669896758">"არ არის არჩეული"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"მიმდინარეობს თქვენ მიერ არჩეული მედიის მომზადება"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> სულ <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>-დან მზადაა"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ფოტოების სარეზერვო ასლები ახლა განთავსებულია"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"გაუქმება"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"უკვე ფოტოების სარეზერვო ასლების ჩათვლით"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"შეგიძლიათ აირჩიოთ ფოტოები <xliff:g id="APP_NAME">%1$s</xliff:g>-ის ანგარიშიდან <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ანგარიში განახლებულია"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"<xliff:g id="USER_ACCOUNT">%1$s</xliff:g>-ის ფოტოები ახლა აქ არის განთავსებული"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"აპის არჩევა"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"აირჩიეთ ანგარიში"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ანგარიშის შეცვლა"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"თქვენი ყველა ფოტოს მიღება"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{აძლევთ უფლებას <xliff:g id="APP_NAME_0">^1</xliff:g>-ს, შეცვალოს ეს აუდიოფაილი?}other{აძლევთ უფლებას <xliff:g id="APP_NAME_1">^1</xliff:g>-ს, შეცვალოს <xliff:g id="COUNT">^2</xliff:g> აუდიოფაილი?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{მიმდინარეობს აუდიოფაილის მოდიფიკაცია…}other{მიმდინარეობს <xliff:g id="COUNT">^1</xliff:g> აუდიოფაილის მოდიფიკაცია…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{აძლევთ უფლებას <xliff:g id="APP_NAME_0">^1</xliff:g>-ს, შეცვალოს ეს ვიდეო?}other{აძლევთ უფლებას <xliff:g id="APP_NAME_1">^1</xliff:g>-ს, შეცვალოს <xliff:g id="COUNT">^2</xliff:g> ვიდეო?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"უსაფრთხოების დაცვა"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ტრანსკოდირების ადგილობრივი გაფრთხილება"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ტრანსკოდირების ადგილობრივი პროგრესი"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ცადეთ მოგვიანებით. თქვენი ფოტოები ხარვეზის აღმოფხვრის შემდეგ იქნება ხელმისაწვდომი."</string> + <string name="dialog_error_title" msgid="636349284077820636">"ზოგიერთი ფოტოს ჩატვირთვა ვერ ხერხდება"</string> + <string name="dialog_button_text" msgid="351366485240852280">"გასაგებია"</string> </resources> diff --git a/res/values-kk/strings.xml b/res/values-kk/strings.xml index c6397d317..28cd54b54 100644 --- a/res/values-kk/strings.xml +++ b/res/values-kk/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Мультимeдиа"</string> <string name="storage_description" msgid="4081716890357580107">"Жергілікті жад"</string> - <string name="app_label" msgid="9035307001052716210">"Мультимедиа қоймасы"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Meдиа"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Meдиафайл таңдағыш"</string> <string name="artist_label" msgid="8105600993099120273">"Орындаушы"</string> <string name="unknown" msgid="2059049215682829375">"Белгісіз"</string> <string name="root_images" msgid="5861633549189045666">"Кескіндер"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Бұлттық мультимедиа қолданбасына келесі жерден кіріңіз:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Жоқ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Бұлттық мультимедиа қолданбасы өзгертілмейді."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Meдиафайл таңдағыш"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Meдиафайл таңдағыш"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Медиафайл синхрондалып жатыр…"</string> <string name="add" msgid="2894574044585549298">"Қосу"</string> <string name="deselect" msgid="4297825044827769490">"Таңдамау"</string> <string name="deselected" msgid="8488133193326208475">"Таңдау алынған"</string> @@ -58,8 +60,10 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Альбомдар жоқ."</string> <string name="picker_view_selected" msgid="2266031384396143883">"Таңдалғанды көру"</string> <string name="picker_photos" msgid="7415035516411087392">"Фотосуреттер"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Aльбомдар"</string> - <string name="picker_preview" msgid="6257414886055861039">"Алдын ала көру"</string> + <string name="picker_preview" msgid="6257414886055861039">"Алғы көрініс"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Жұмыс профиліне ауысу"</string> <string name="picker_personal_profile" msgid="639484258397758406">"Жеке профильге ауысу"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Әкімшіңіз бөгеген"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> элемент}other{<xliff:g id="COUNT_1">^1</xliff:g> элемент}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Қосу (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Рұқсат (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ешқайсысына рұқсат етпеу"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Жүктеп алынғандар"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Таңдаулылар"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Бейнені ойнату кезінде қиындық туындады"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Интернет байланысын тексеріп, әрекетті қайталаңыз."</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Қайталау"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Бұлтқа сақталған медиафайл енді <xliff:g id="PKG_NAME">%1$s</xliff:g> қолданбасында қолжетімді."</string> <string name="not_selected" msgid="2244008151669896758">"таңдалмаған"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Таңдалған мультимедиа әзірленіп жатыр"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> дайын"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Бас тарту"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Сақтық көшірмесі жасалған фотосуреттер қосылды"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Фотосуреттерді <xliff:g id="APP_NAME">%1$s</xliff:g> қолданбасының <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> аккаунтынан таңдай аласыз."</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> аккаунты жаңартылды"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Қолданба таңдау"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Аккаунт таңдау"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Аккаунтты өзгерту"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Барлық сурет алынып жатыр."</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> қолданбасына осы аудиофайлды өзгертуге рұқсат етілсін бе?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> қолданбасына <xliff:g id="COUNT">^2</xliff:g> аудиофайлды өзгертуге рұқсат етілсін бе?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Аудиофайл өзгертілуде…}other{<xliff:g id="COUNT">^1</xliff:g> аудиофайл өзгертілуде…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> қолданбасына осы бейнені өзгертуге рұқсат етілсін бе?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> қолданбасына <xliff:g id="COUNT">^2</xliff:g> бейнені өзгертуге рұқсат етілсін бе?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Қауіпсіздікті қорғау"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Түбірлік транскодтау туралы хабарландырулар"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Түбірлік транскодтау прогресі"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Кейінірек қайталап көріңіз. Мәселе шешілген соң, фотосуреттеріңіз қолжетімді болады."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Кейбір фотосуреттерді жүктеу мүмкін емес"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Түсінікті"</string> </resources> diff --git a/res/values-km/strings.xml b/res/values-km/strings.xml index eca210081..6b10fc566 100644 --- a/res/values-km/strings.xml +++ b/res/values-km/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"មេឌៀ"</string> <string name="storage_description" msgid="4081716890357580107">"ទំហំផ្ទុកមូលដ្ឋាន"</string> - <string name="app_label" msgid="9035307001052716210">"ទំហំផ្ទុកមេឌៀ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"មេឌៀ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"មុខងារជ្រើសរើសមេឌៀ"</string> <string name="artist_label" msgid="8105600993099120273">"សិល្បករ"</string> <string name="unknown" msgid="2059049215682829375">"មិនស្គាល់"</string> <string name="root_images" msgid="5861633549189045666">"រូបភាព"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ចូលប្រើប្រាស់មេឌៀលើពពកពី"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"គ្មាន"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"មិនអាចប្ដូរកម្មវិធីមេឌៀពពកនៅពេលនេះបានទេ។"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"មុខងារជ្រើសរើសមេឌៀ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"មុខងារជ្រើសរើសមេឌៀ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"កំពុងធ្វើសមកាលកម្មមេឌៀ…"</string> <string name="add" msgid="2894574044585549298">"បញ្ចូល"</string> <string name="deselect" msgid="4297825044827769490">"ដកការជ្រើសរើស"</string> <string name="deselected" msgid="8488133193326208475">"បានដកការជ្រើសរើស"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"គ្មានអាល់ប៊ុមទេ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"មើលអ្វីដែលបានជ្រើសរើស"</string> <string name="picker_photos" msgid="7415035516411087392">"រូបថត"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"អាល់ប៊ុម"</string> <string name="picker_preview" msgid="6257414886055861039">"មើលសាកល្បង"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ប្ដូរទៅកម្រងព័ត៌មានការងារ"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{ធាតុ <xliff:g id="COUNT_0">^1</xliff:g>}other{ធាតុ <xliff:g id="COUNT_1">^1</xliff:g>}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"បញ្ចូល (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"អនុញ្ញាត (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"អនុញ្ញាត \"គ្មាន\""</string> <string name="picker_category_camera" msgid="4857367052026843664">"កាមេរ៉ា"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ការទាញយក"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"សំណព្វ"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"មានបញ្ហាក្នុងការចាក់វីដេអូ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ពិនិត្យការតភ្ជាប់អ៊ីនធឺណិតរបស់អ្នក រួចព្យាយាមម្ដងទៀត"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ព្យាយាមម្ដងទៀត"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ឥឡូវនេះមានមេឌៀក្នុងប្រព័ន្ធពពកពី <xliff:g id="PKG_NAME">%1$s</xliff:g> ហើយ"</string> <string name="not_selected" msgid="2244008151669896758">"មិនបានជ្រើសរើសទេ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"កំពុងរៀបចំមេឌៀដែលអ្នកបានជ្រើសរើស"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> នៃ <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> រួចរាល់ហើយ"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"បោះបង់"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ឥឡូវនេះមានរួមបញ្ចូលរូបថតដែលបានបម្រុងទុក"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"អ្នកអាចជ្រើសរើសរូបថតពីគណនី <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> របស់ <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"អ្នកអាចជ្រើសរើសរូបថតពីគណនី <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"បានធ្វើបច្ចុប្បន្នភាពគណនី <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"ឥឡូវនេះ រូបថតពី <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> ត្រូវបានរួមបញ្ចូលនៅទីនេះ"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"ជ្រើសរើសកម្មវិធីមេឌៀលើពពក"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"ការការពារសុវត្ថិភាព"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ការជូនដំណឹងអំពីការបំប្លែងកូដដើម"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ដំណើរវិវឌ្ឍនៃការបំប្លែងកូដដើម"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ។ រូបថតរបស់អ្នកនឹងអាចប្រើបាន បន្ទាប់ពីដោះស្រាយបញ្ហា។"</string> + <string name="dialog_error_title" msgid="636349284077820636">"មិនអាចផ្ទុករូបថតមួយចំនួនបានទេ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"យល់ហើយ"</string> </resources> diff --git a/res/values-kn/strings.xml b/res/values-kn/strings.xml index c162efc99..7e3af0bc1 100644 --- a/res/values-kn/strings.xml +++ b/res/values-kn/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"ಮಾಧ್ಯಮ"</string> <string name="storage_description" msgid="4081716890357580107">"ಸ್ಥಳೀಯ ಸಂಗ್ರಹಣೆ"</string> - <string name="app_label" msgid="9035307001052716210">"ಮಾಧ್ಯಮ ಸಂಗ್ರಹಣೆ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"ಮಾಧ್ಯಮ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"ಮಾಧ್ಯಮ ಪಿಕರ್"</string> <string name="artist_label" msgid="8105600993099120273">"ಕಲಾವಿದರು"</string> <string name="unknown" msgid="2059049215682829375">"ಅಪರಿಚಿತ"</string> <string name="root_images" msgid="5861633549189045666">"ಚಿತ್ರಗಳು"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ಇದರಿಂದ ಕ್ಲೌಡ್ ಮಾಧ್ಯಮವನ್ನು ಪ್ರವೇಶಿಸಿ"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ಯಾವುದೂ ಅಲ್ಲ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ಇದೀಗ ಕ್ಲೌಡ್ ಮೀಡಿಯಾ ಆ್ಯಪ್ ಬದಲಾಯಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"ಮಾಧ್ಯಮ ಪಿಕರ್"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"ಮಾಧ್ಯಮ ಪಿಕರ್"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"ಮಾಧ್ಯಮವನ್ನು ಸಿಂಕ್ ಮಾಡಲಾಗುತ್ತಿದೆ…"</string> <string name="add" msgid="2894574044585549298">"ಸೇರಿಸಿ"</string> <string name="deselect" msgid="4297825044827769490">"ಆಯ್ಕೆ ರದ್ದುಮಾಡಿ"</string> <string name="deselected" msgid="8488133193326208475">"ಆಯ್ಕೆ ರದ್ದುಮಾಡಲಾಗಿದೆ"</string> @@ -58,20 +60,23 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ಯಾವುದೇ ಆಲ್ಬಮ್ಗಳಿಲ್ಲ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ಆಯ್ಕೆಮಾಡಿರುವುದನ್ನು ವೀಕ್ಷಿಸಿ"</string> <string name="picker_photos" msgid="7415035516411087392">"ಫೋಟೋಗಳು"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ಆಲ್ಬಮ್ಗಳು"</string> <string name="picker_preview" msgid="6257414886055861039">"ಪೂರ್ವವೀಕ್ಷಣೆ"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"ಕೆಲಸಕ್ಕೆ ಬದಲಿಸಿ"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"ವೈಯಕ್ತಿಕಕ್ಕೆ ಬದಲಿಸಿ"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"ಉದ್ಯೋಗ ಪ್ರೊಫೈಲ್ಗೆ ಬದಲಿಸಿ"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"ವೈಯಕ್ತಿಕ ಪ್ರೊಫೈಲ್ಗೆ ಬದಲಿಸಿ"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"ನಿಮ್ಮ ನಿರ್ವಾಹಕರು ನಿರ್ಬಂಧಿಸಿದ್ದಾರೆ"</string> - <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ ಮೂಲಕ ಅಧಿಕೃತ ಡೇಟಾವನ್ನು ಪ್ರವೇಶಿಸಲು ಅನುಮತಿಸಲಾಗುವುದಿಲ್ಲ"</string> + <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ನಿಂದ ಉದ್ಯೋಗದ ಡೇಟಾವನ್ನು ಆ್ಯಕ್ಸೆಸ್ ಮಾಡಲು ಅನುಮತಿಸಲಾಗುವುದಿಲ್ಲ"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ ಮೂಲಕ ಅಧಿಕೃತ ಡೇಟಾವನ್ನು ಪ್ರವೇಶಿಸಲು ಅನುಮತಿಸಲಾಗುವುದಿಲ್ಲ"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳನ್ನು ವಿರಾಮಗೊಳಿಸಲಾಗಿದೆ"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"ಕೆಲಸದ ಫೋಟೋಗಳನ್ನು ತೆರೆಯಲು, ನಿಮ್ಮ ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳನ್ನು ಆನ್ ಮಾಡಿ ನಂತರ ಪುನಃ ಪ್ರಯತ್ನಿಸಿ"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"ಈ ಆ್ಯಪ್ ನೀವು ಆಯ್ಕೆಮಾಡಿದ ಫೋಟೋಗಳನ್ನು ಮಾತ್ರ ಪ್ರವೇಶಿಸಬಹುದು"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"ಈ ಆ್ಯಪ್ ನೀವು ಆಯ್ಕೆಮಾಡಿದ ಫೋಟೋಗಳನ್ನು ಮಾತ್ರ ಆ್ಯಕ್ಸೆಸ್ ಮಾಡಬಹುದು"</string> <string name="picker_header_permissions" msgid="675872774407768495">"ಈ ಆ್ಯಪ್ ಅನ್ನು ಆ್ಯಕ್ಸೆಸ್ ಮಾಡಲು ನೀವು ಅನುಮತಿಸುವ ಫೋಟೋಗಳು ಮತ್ತು ವೀಡಿಯೊಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> - <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g>ಐಟಂ}one{<xliff:g id="COUNT_1">^1</xliff:g> ಐಟಂಗಳು}other{<xliff:g id="COUNT_1">^1</xliff:g> ಐಟಂಗಳು}}"</string> + <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ಐಟಂ}one{<xliff:g id="COUNT_1">^1</xliff:g> ಐಟಂಗಳು}other{<xliff:g id="COUNT_1">^1</xliff:g> ಐಟಂಗಳು}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"ಸೇರಿಸಿ (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"(<xliff:g id="COUNT">^1</xliff:g>) ಅನುಮತಿಸಿ"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ಯಾವುದನ್ನೂ ಅನುಮತಿಸಬೇಡಿ"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ಕ್ಯಾಮರಾ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ಡೌನ್ಲೋಡ್ಗಳು"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ಮೆಚ್ಚಿನವುಗಳು"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ವೀಡಿಯೊ ಪ್ಲೇ ಮಾಡಲು ಸಮಸ್ಯೆಯಾಗಿದೆ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ನಿಮ್ಮ ಇಂಟರ್ನೆಟ್ ಕನೆಕ್ಷನ್ ಅನ್ನು ಪರಿಶೀಲಿಸಿ ಹಾಗೂ ಪುನಃ ಪ್ರಯತ್ನಿಸಿ"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ಮರುಪ್ರಯತ್ನಿಸಿ"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ಕ್ಲೌಡ್ ಮಾಧ್ಯಮವು ಈಗ <xliff:g id="PKG_NAME">%1$s</xliff:g> ನಿಂದ ಲಭ್ಯವಿದೆ"</string> <string name="not_selected" msgid="2244008151669896758">"ಆಯ್ಕೆಮಾಡಲಾಗಿಲ್ಲ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"ನಿಮ್ಮ ಆಯ್ಕೆಮಾಡಿದ ಮೀಡಿಯಾವನ್ನು ಸಿದ್ಧಪಡಿಸಲಾಗುತ್ತಿದೆ"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ರಲ್ಲಿ <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> ಸಿದ್ಧವಾಗಿವೆ"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ರದ್ದುಮಾಡಿ"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ಬ್ಯಾಕಪ್ ಮಾಡಲಾದ ಫೋಟೋಗಳನ್ನು ಈಗ ಸೇರಿಸಲಾಗಿದೆ"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> ಖಾತೆಯ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ನಲ್ಲಿರುವ ಫೋಟೋಗಳನ್ನು ನೀವು ಆಯ್ಕೆಮಾಡಬಹುದು"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ಖಾತೆಯನ್ನು ಅಪ್ಡೇಟ್ ಮಾಡಲಾಗಿದೆ"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ಆ್ಯಪ್ ಅನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"ಖಾತೆಯನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ಖಾತೆಯನ್ನು ಬದಲಾಯಿಸಿ"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ನಿಮ್ಮ ಎಲ್ಲಾ ಫೋಟೋಗಳನ್ನು ಪಡೆಯಲಾಗುತ್ತಿದೆ"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{ಈ ಆಡಿಯೋ ಫೈಲ್ ಅನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_0">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}one{ಈ <xliff:g id="COUNT">^2</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}other{ಈ <xliff:g id="COUNT">^2</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ಆಡಿಯೋ ಫೈಲ್ ಅನ್ನು ಮಾರ್ಪಡಿಸಲಾಗುತ್ತಿದೆ…}one{<xliff:g id="COUNT">^1</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲಾಗುತ್ತಿದೆ…}other{<xliff:g id="COUNT">^1</xliff:g> ಆಡಿಯೋ ಫೈಲ್ಗಳನ್ನು ಮಾರ್ಪಡಿಸಲಾಗುತ್ತಿದೆ…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{ಈ ವೀಡಿಯೊವನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_0">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}one{ಈ <xliff:g id="COUNT">^2</xliff:g> ವೀಡಿಯೊಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}other{ಈ <xliff:g id="COUNT">^2</xliff:g> ವೀಡಿಯೊಗಳನ್ನು ಮಾರ್ಪಡಿಸಲು <xliff:g id="APP_NAME_1">^1</xliff:g> ಗೆ ಅನುಮತಿ ನೀಡಬೇಕೇ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"ಭದ್ರತಾ ರಕ್ಷಣೆ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ನೇಟಿವ್ ಟ್ರಾನ್ಸ್ಕೋಡ್ ಅಲರ್ಟ್ಗಳು"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ನೇಟಿವ್ ಟ್ರಾನ್ಸ್ಕೋಡ್ ಪ್ರಗತಿ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ನಂತರ ಪುನಃ ಪ್ರಯತ್ನಿಸಿ. ಸಮಸ್ಯೆ ಬಗೆಹರಿದ ನಂತರ ನಿಮ್ಮ ಫೋಟೋಗಳು ಲಭ್ಯವಿರುತ್ತವೆ."</string> + <string name="dialog_error_title" msgid="636349284077820636">"ಕೆಲವು ಫೋಟೋಗಳನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ಅರ್ಥವಾಯಿತು"</string> </resources> diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml index 9e23b0934..3b7432a8b 100644 --- a/res/values-ko/strings.xml +++ b/res/values-ko/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"미디어"</string> <string name="storage_description" msgid="4081716890357580107">"로컬 저장소"</string> - <string name="app_label" msgid="9035307001052716210">"미디어 저장소"</string> - <string name="picker_app_label" msgid="4254039089502164761">"미디어"</string> + <string name="picker_app_label" msgid="1195424381053599122">"미디어 선택 도구"</string> <string name="artist_label" msgid="8105600993099120273">"아티스트"</string> <string name="unknown" msgid="2059049215682829375">"알 수 없음"</string> <string name="root_images" msgid="5861633549189045666">"이미지"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"클라우드 미디어 액세스 위치"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"없음"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"현재 클라우드 미디어 앱을 변경할 수 없습니다."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"미디어 선택 도구"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"미디어 선택 도구"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"미디어 동기화 중…"</string> <string name="add" msgid="2894574044585549298">"추가"</string> <string name="deselect" msgid="4297825044827769490">"선택 해제"</string> <string name="deselected" msgid="8488133193326208475">"선택 해제됨"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"앨범 없음"</string> <string name="picker_view_selected" msgid="2266031384396143883">"선택 항목 보기"</string> <string name="picker_photos" msgid="7415035516411087392">"사진"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"앨범"</string> <string name="picker_preview" msgid="6257414886055861039">"미리보기"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"직장으로 전환"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"개인으로 전환"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"직장 프로필로 전환"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"개인 프로필로 전환"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"관리자가 차단함"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"개인 앱에서는 업무 데이터에 액세스할 수 없습니다."</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"직장 앱에서는 개인 데이터에 액세스할 수 없습니다."</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{항목 <xliff:g id="COUNT_0">^1</xliff:g>개}other{항목 <xliff:g id="COUNT_1">^1</xliff:g>개}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"추가(<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"허용(<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"허용 안 함"</string> <string name="picker_category_camera" msgid="4857367052026843664">"카메라"</string> <string name="picker_category_downloads" msgid="793866660287361900">"다운로드"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"즐겨찾기"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"동영상 재생 중 문제 발생"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"인터넷 연결 상태를 확인하고 다시 시도해 주세요."</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"다시 시도"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"이제 <xliff:g id="PKG_NAME">%1$s</xliff:g>에서 클라우드 미디어를 사용할 수 있습니다."</string> <string name="not_selected" msgid="2244008151669896758">"선택되지 않음"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"선택한 미디어를 준비하는 중"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>개 준비됨"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"취소"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"이제 백업된 사진이 포함됨"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> 계정(<xliff:g id="USER_ACCOUNT">%2$s</xliff:g>)에서 사진을 선택할 수 있습니다."</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> 계정 업데이트됨"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"앱 선택"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"계정 선택"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"계정 변경"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"모든 사진 가져오는 중"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>에서 이 오디오 파일을 수정하도록 허용하시겠습니까?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>에서 오디오 파일 <xliff:g id="COUNT">^2</xliff:g>개를 수정하도록 허용하시겠습니까?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{오디오 파일 수정 중…}other{오디오 파일 <xliff:g id="COUNT">^1</xliff:g>개 수정 중…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>에서 이 동영상을 수정하도록 허용하시겠습니까?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>에서 동영상 <xliff:g id="COUNT">^2</xliff:g>개를 수정하도록 허용하시겠습니까?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"안전 보안"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"네이티브 트랜스코드 알림"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"네이티브 트랜스코드 진행 상황"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"나중에 다시 시도해 주세요. 문제가 해결된 후 사진을 사용할 수 있습니다."</string> + <string name="dialog_error_title" msgid="636349284077820636">"일부 사진을 로드할 수 없음"</string> + <string name="dialog_button_text" msgid="351366485240852280">"확인"</string> </resources> diff --git a/res/values-ky/strings.xml b/res/values-ky/strings.xml index 469592e96..47578555a 100644 --- a/res/values-ky/strings.xml +++ b/res/values-ky/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Мультимедия"</string> <string name="storage_description" msgid="4081716890357580107">"Жергиликтүү сактагыч"</string> - <string name="app_label" msgid="9035307001052716210">"Медиа сактагыч"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Медиа"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Медиа файлдарды тандагыч"</string> <string name="artist_label" msgid="8105600993099120273">"Аткаруучу"</string> <string name="unknown" msgid="2059049215682829375">"Белгисиз"</string> <string name="root_images" msgid="5861633549189045666">"Сүрөттөр"</string> @@ -46,18 +45,23 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Төмөнкүдөн алынган булуттагы мультимедианы колдонуу:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Жок"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Мультимедиа колдонмосу өзгөргөн жок."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Медиа файлдарды тандагыч"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Медиа файлдарды тандагыч"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Медиа файлдар шайкештирилүүдө…"</string> <string name="add" msgid="2894574044585549298">"Кошуу"</string> <string name="deselect" msgid="4297825044827769490">"Тандоодон чыгаруу"</string> <string name="deselected" msgid="8488133193326208475">"Тандоодон чыгарылды"</string> <string name="select" msgid="2704765470563027689">"Тандоо"</string> <string name="selected" msgid="9151797369975828124">"Тандалды"</string> - <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> объектке чейин тандаңыз}other{<xliff:g id="COUNT_1">^1</xliff:g> объектке чейин тандаңыз}}"</string> + <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> нерсеге чейин тандаңыз}other{<xliff:g id="COUNT_1">^1</xliff:g> нерсеге чейин тандаңыз}}"</string> <string name="recent" msgid="6694613584743207874">"Акыркы"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"Сүрөттөр же видеолор жок"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Колдоого алынган сүрөттөр же видеолор жок"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"Альбомдор жок"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Тандалганды көрүү"</string> <string name="picker_photos" msgid="7415035516411087392">"Сүрөттөр"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Альбомдор"</string> <string name="picker_preview" msgid="6257414886055861039">"Алдын ала көрүү"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Жумуш профилине которулуу"</string> @@ -67,11 +71,12 @@ <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Жеке маалыматка жумуш колдонмосунан кирүүгө тыюу салынат"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Жумуш колдонмолору тындырылды"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"Жумуш сүрөттөрүн ачуу үчүн жумуш колдонмолорун иштетип, кайра аракет кылыңыз"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"Бул колдонмо сиз тандаган сүрөттөргө гана кире алат"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"Бул колдонмого сиз тандаган сүрөттөр гана жеткиликтүү"</string> <string name="picker_header_permissions" msgid="675872774407768495">"Бул колдонмо кире турган сүрөттөрдү жана видеолорду тандаңыз"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> нерсе}other{<xliff:g id="COUNT_1">^1</xliff:g> нерсе}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Кошуу (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Уруксат берүү (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Уруксат берилбейт"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Жүктөлүп алынгандар"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Тандалмалар"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Видеону ойнотууда маселе келип чыкты"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Интернет байланышыңызды текшерип, кайталап көрүңүз"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Кайталоо"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Булуттагы медиа эми <xliff:g id="PKG_NAME">%1$s</xliff:g> кызматында жеткиликтүү"</string> <string name="not_selected" msgid="2244008151669896758">"тандалган жок"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Тандаган медиа файлдарыңыз даярдалууда"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ичинен <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> даяр"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Жокко чыгаруу"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Эми камдык көчүрмөсү сакталган сүрөттөр камтылат"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> аккаунтундагы (<xliff:g id="USER_ACCOUNT">%2$s</xliff:g>) сүрөттөрдү тандай аласыз"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> аккаунту жаңыртылды"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Колдонмо тандаңыз"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Аккаунт тандоо"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Аккаунтту өзгөртүү"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Бардык сүрөттөрүңүз алынууда"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> колдонмосу бул аудио файлды өзгөртсүнбү?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> колдонмосу <xliff:g id="COUNT">^2</xliff:g> аудио файлды өзгөртсүнбү?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Аудио файл өзгөртүлүүдө…}other{<xliff:g id="COUNT">^1</xliff:g> аудио файл өзгөртүлүүдө…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> колдонмосу бул видеону өзгөртсүнбү?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> колдонмосу <xliff:g id="COUNT">^2</xliff:g> видеону өзгөртсүнбү?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Коопсуздукту коргоо"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Камтылган транскоддоо эскертүүлөрү"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Камтылган транскоддоо жүргүзүлүүдө"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Бир аздан кийин кайталап көрүңүз. Сүрөттөрүңүздү маселе чечилгенден кийин көрө аласыз."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Айрым сүрөттөр жүктөлбөй жатат"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Түшүндүм"</string> </resources> diff --git a/res/values-lo/strings.xml b/res/values-lo/strings.xml index e07d1a326..2ba9646b2 100644 --- a/res/values-lo/strings.xml +++ b/res/values-lo/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"ມີເດຍ"</string> <string name="storage_description" msgid="4081716890357580107">"ບ່ອນຈັດເກັບຂໍ້ມູນໃນເຄື່ອງ"</string> - <string name="app_label" msgid="9035307001052716210">"ພື້ນທີ່ຈັດເກັບຂໍ້ມູນມີເດຍ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"ສື່"</string> + <string name="picker_app_label" msgid="1195424381053599122">"ຕົວເລືອກມີເດຍ"</string> <string name="artist_label" msgid="8105600993099120273">"ສິນລະປິນ"</string> <string name="unknown" msgid="2059049215682829375">"ບໍ່ຮູ້ຈັກ"</string> <string name="root_images" msgid="5861633549189045666">"ຮູບພາບ"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ເຂົ້າເຖິງມີເດຍໃນລະບົບຄລາວຈາກ"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ບໍ່ມີ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ບໍ່ສາມາດປ່ຽນແອັບມີເດຍໃນລະບົບຄລາວໄດ້ໃນຕອນນີ້."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"ຕົວເລືອກມີເດຍ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"ຕົວເລືອກມີເດຍ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"ກຳລັງຊິ້ງຂໍ້ມູນມີເດຍ…"</string> <string name="add" msgid="2894574044585549298">"ເພີ່ມ"</string> <string name="deselect" msgid="4297825044827769490">"ເຊົາເລືອກ"</string> <string name="deselected" msgid="8488133193326208475">"ເຊົາເລືອກແລ້ວ"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ບໍ່ມີອະລະບ້ຳ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ເບິ່ງອັນທີ່ເລືອກໄວ້"</string> <string name="picker_photos" msgid="7415035516411087392">"ຮູບພາບ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ອະລະບ້ຳ"</string> <string name="picker_preview" msgid="6257414886055861039">"ຕົວຢ່າງ"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ສະຫຼັບໄປໂປຣໄຟລ໌ວຽກ"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ລາຍການ}other{<xliff:g id="COUNT_1">^1</xliff:g> ລາຍການ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"ເພີ່ມ (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"ອະນຸຍາດ (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ບໍ່ອະນຸຍາດ"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ກ້ອງຖ່າຍຮູບ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ດາວໂຫຼດ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ລາຍການທີ່ມັກ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ບັນຫາໃນການຫຼິ້ນວິດີໂອ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ກະລຸນາກວດສອບການເຊື່ອມຕໍ່ອິນເຕີເນັດຂອງທ່ານແລ້ວລອງໃໝ່"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ລອງໃໝ່"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ຕອນນີ້ສາມາດໃຊ້ມີເດຍຄລາວຈາກ <xliff:g id="PKG_NAME">%1$s</xliff:g> ໄດ້ແລ້ວ"</string> <string name="not_selected" msgid="2244008151669896758">"ບໍ່ໄດ້ເລືອກ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"ກຳລັງກຽມມີເດຍທີ່ທ່ານເລືອກໄວ້"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"ພ້ອມແລ້ວ <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> ຈາກທັງໝົດ <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ຍົກເລີກ"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ຕອນນີ້ຮວມຮູບພາບທີ່ສຳຮອງຂໍ້ມູນໄວ້ແລ້ວ"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ທ່ານສາມາດເລືອກຮູບພາບໄດ້ຈາກບັນຊີ <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"ອັບເດດບັນຊີ <xliff:g id="APP_NAME">%1$s</xliff:g> ແລ້ວ"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ເລືອກແອັບ"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"ເລືອກບັນຊີ"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ປ່ຽນບັນຊີ"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ກຳລັງໂຫຼດຮູບພາບທັງໝົດຂອງທ່ານ"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_0">^1</xliff:g> ແກ້ໄຂໄຟລ໌ສຽງນີ້ບໍ?}other{ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_1">^1</xliff:g> ແກ້ໄຂໄຟລ໌ສຽງ <xliff:g id="COUNT">^2</xliff:g> ໄຟລ໌ບໍ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ກຳລັງແກ້ໄຂໄຟລ໌ສຽງ…}other{ກຳລັງແກ້ໄຂໄຟລ໌ສຽງ <xliff:g id="COUNT">^1</xliff:g> ໄຟລ໌…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_0">^1</xliff:g> ແກ້ໄຂວິດີໂອນີ້ບໍ?}other{ອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME_1">^1</xliff:g> ແກ້ໄຂວິດີໂອ <xliff:g id="COUNT">^2</xliff:g> ລາຍການບໍ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"ການປ້ອງກັນຄວາມປອດໄພ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ແຈ້ງເຕືອນການປ່ຽນຮູບແບບລະຫັດເດີມ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ຄວາມຄືບໜ້າຂອງການປ່ຽນຮູບແບບລະຫັດເດີມ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ກະລຸນາລອງໃໝ່ໃນພາຍຫຼັງ. ຈະມີການສະແດງຮູບພາບຂອງທ່ານເມື່ອບັນຫາໄດ້ຮັບການແກ້ໄຂແລ້ວ."</string> + <string name="dialog_error_title" msgid="636349284077820636">"ບໍ່ສາມາດໂຫຼດບາງຮູບພາບໄດ້"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ເຂົ້າໃຈແລ້ວ"</string> </resources> diff --git a/res/values-lt/strings.xml b/res/values-lt/strings.xml index f491e7e44..29a093fec 100644 --- a/res/values-lt/strings.xml +++ b/res/values-lt/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Medija"</string> <string name="storage_description" msgid="4081716890357580107">"Vietinė saugykla"</string> - <string name="app_label" msgid="9035307001052716210">"Medijos saugykla"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Medija"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Medijos pasirinkimo priemonė"</string> <string name="artist_label" msgid="8105600993099120273">"Atlikėjas"</string> <string name="unknown" msgid="2059049215682829375">"Nežinoma"</string> <string name="root_images" msgid="5861633549189045666">"Vaizdai"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Pasiekti mediją debesyje iš"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nėra"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Šiuo metu nepavyko pakeisti debesies medijos programos."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Medijos pasirinkimo priemonė"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Medijos pasirinkimo priemonė"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sinchronizuojama medija…"</string> <string name="add" msgid="2894574044585549298">"Pridėti"</string> <string name="deselect" msgid="4297825044827769490">"Panaikinti pasirinkimą"</string> <string name="deselected" msgid="8488133193326208475">"Pasirinkimas panaikintas"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nėra jokių albumų"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Žiūrėti pasirinktus"</string> <string name="picker_photos" msgid="7415035516411087392">"Nuotraukos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumai"</string> <string name="picker_preview" msgid="6257414886055861039">"Peržiūra"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Perjungti į darbo profilį"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> elementas}one{<xliff:g id="COUNT_1">^1</xliff:g> elementas}few{<xliff:g id="COUNT_1">^1</xliff:g> elementai}many{<xliff:g id="COUNT_1">^1</xliff:g> elemento}other{<xliff:g id="COUNT_1">^1</xliff:g> elementų}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Pridėti (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Leisti (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Niekas neleidžiama"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Vaizdo kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Atsisiuntimai"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Mėgstamiausi"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Paleidžiant vaizdo įrašą kilo problema"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Patikrinkite interneto ryšį ir bandykite dar kartą"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Bandyti dar kartą"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Debesyje esanti medija dabar pasiekiama iš „<xliff:g id="PKG_NAME">%1$s</xliff:g>“"</string> <string name="not_selected" msgid="2244008151669896758">"nepasirinkta"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Ruošiami pasirinkti medijos failai"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Paruošta: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> iš <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Atšaukti"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Dabar įtraukiamos atsarginės nuotraukų kopijos"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Galite pasirinkti nuotraukas iš „<xliff:g id="APP_NAME">%1$s</xliff:g>“ paskyros <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"„<xliff:g id="APP_NAME">%1$s</xliff:g>“ paskyra atnaujinta"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Pasirinkti programą"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Pasirinkti paskyrą"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Pakeisti paskyrą"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Gaunamos visos nuotraukos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Leisti programai „<xliff:g id="APP_NAME_0">^1</xliff:g>“ keisti šį garso failą?}one{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failą?}few{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failus?}many{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failo?}other{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> garso failų?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Keičiamas garso failas…}one{Keičiamas <xliff:g id="COUNT">^1</xliff:g> garso failas…}few{Keičiami <xliff:g id="COUNT">^1</xliff:g> garso failai…}many{Keičiama <xliff:g id="COUNT">^1</xliff:g> garso failo…}other{Keičiama <xliff:g id="COUNT">^1</xliff:g> garso failų…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Leisti programai „<xliff:g id="APP_NAME_0">^1</xliff:g>“ keisti šį vaizdo įrašą?}one{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> vaizdo įrašą?}few{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> vaizdo įrašus?}many{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> vaizdo įrašo?}other{Leisti programai „<xliff:g id="APP_NAME_1">^1</xliff:g>“ keisti <xliff:g id="COUNT">^2</xliff:g> vaizdo įrašų?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Apsauga"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Vėliau bandykite dar kartą. Nuotraukos bus pasiekiamos išsprendus problemą."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nepavyko įkelti kai kurių nuotraukų"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Supratau"</string> </resources> diff --git a/res/values-lv/strings.xml b/res/values-lv/strings.xml index 4af22b77f..ad857f8a5 100644 --- a/res/values-lv/strings.xml +++ b/res/values-lv/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multivide"</string> <string name="storage_description" msgid="4081716890357580107">"Lokālā krātuve"</string> - <string name="app_label" msgid="9035307001052716210">"Multivides krātuve"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multivide"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Multivides atlasītājs"</string> <string name="artist_label" msgid="8105600993099120273">"Izpildītājs"</string> <string name="unknown" msgid="2059049215682829375">"Nezināms"</string> <string name="root_images" msgid="5861633549189045666">"Attēli"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Piekļūstiet mākoņa multivides saturam no"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nav"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Nevarēja mainīt mākoņa multivides lietotni."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Multivides atlasītājs"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Multivides atlasītājs"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Notiek multivides satura sinhronizēšana…"</string> <string name="add" msgid="2894574044585549298">"Pievienot"</string> <string name="deselect" msgid="4297825044827769490">"Noņemt atlasi"</string> <string name="deselected" msgid="8488133193326208475">"Atlase noņemta"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nav albumu"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Skatīt atlasīto"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotoattēli"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumi"</string> <string name="picker_preview" msgid="6257414886055861039">"Priekšskatījums"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Pārslēgties uz darba profilu"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> vienums}zero{<xliff:g id="COUNT_1">^1</xliff:g> vienumu}one{<xliff:g id="COUNT_1">^1</xliff:g> vienums}other{<xliff:g id="COUNT_1">^1</xliff:g> vienumi}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Pievienot <xliff:g id="COUNT">^1</xliff:g>"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Atļaut (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Neatļaut nevienu"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Lejupielādes"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Izlase"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Atskaņojot videoklipu, radās kļūda"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Pārbaudiet interneta savienojumu un mēģiniet vēlreiz"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Mēģināt vēlreiz"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Tagad mākoņa multivides saturs ir pieejams, izmantojot lietotni <xliff:g id="PKG_NAME">%1$s</xliff:g>."</string> <string name="not_selected" msgid="2244008151669896758">"nav atlasīts"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Notiek jūsu atlasītā multivides satura sagatavošana"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Gatavs: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> no <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Atcelt"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Dublētie fotoattēli ir iekļauti"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Varat atlasīt fotoattēlus no lietotnes “<xliff:g id="APP_NAME">%1$s</xliff:g>” konta <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Lietotnes “<xliff:g id="APP_NAME">%1$s</xliff:g>” konts ir atjaunināts"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Izvēlēties lietotni"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Izvēlēties kontu"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Mainīt kontu"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Notiek visu jūsu fotoattēlu ielāde…"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vai atļaut lietotnei <xliff:g id="APP_NAME_0">^1</xliff:g> pārveidot šo audio failu?}zero{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> audio failus?}one{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> audio failu?}other{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> audio failus?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Notiek audio faila pārveidošana…}zero{Notiek <xliff:g id="COUNT">^1</xliff:g> audio failu pārveidošana…}one{Notiek <xliff:g id="COUNT">^1</xliff:g> audio faila pārveidošana…}other{Notiek <xliff:g id="COUNT">^1</xliff:g> audio failu pārveidošana…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vai atļaut lietotnei <xliff:g id="APP_NAME_0">^1</xliff:g> pārveidot šo videoklipu?}zero{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> videoklipus?}one{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> videoklipu?}other{Vai atļaut lietotnei <xliff:g id="APP_NAME_1">^1</xliff:g> pārveidot <xliff:g id="COUNT">^2</xliff:g> videoklipus?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Drošības aizsardzība"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Brīdinājumi par mantotā formāta pārkodēšanu"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Mantotā formāta pārkodēšanas norise"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Vēlāk mēģiniet vēlreiz. Fotoattēli būs pieejami, tiklīdz problēma būs novērsta."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nevar ielādēt dažus fotoattēlus"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Labi"</string> </resources> diff --git a/res/values-mk/strings.xml b/res/values-mk/strings.xml index 1c852cae5..cb95f5475 100644 --- a/res/values-mk/strings.xml +++ b/res/values-mk/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Аудио-визуелни содржини"</string> <string name="storage_description" msgid="4081716890357580107">"Локална меморија"</string> - <string name="app_label" msgid="9035307001052716210">"Капацитет за аудиовизуелни содржини"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Аудиовизуелни содржини"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Избирач на аудиовизуелни содржини"</string> <string name="artist_label" msgid="8105600993099120273">"Изведувач"</string> <string name="unknown" msgid="2059049215682829375">"Непознат"</string> <string name="root_images" msgid="5861633549189045666">"Слики"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Апликација за содржини во облак"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Апликација за содржини во облак"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Апликација за аудиовизуелни содржини во облак"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Пристап до вашите аудиовизуелни содржини во облак кога некоја апликација или веб-сајт ќе ве праша да изберете фотографии или видеа"</string> - <string name="picker_settings_selection_message" msgid="245453573086488596">"Пристапете до аудиовизуелните содржини во облак од"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Пристапувајте до вашите аудиовизуелни содржини во облак кога некоја апликација или веб-сајт ќе побара да изберете фотографии или видеа"</string> + <string name="picker_settings_selection_message" msgid="245453573086488596">"Пристапувајте до аудиовизуелните содржини во облак од"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Нема"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Не може да се промени апликацијата за аудиовизуелни содржини во облак во моментов."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Избирач на аудиовизуелни содржини"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Избирач на аудиовизуелни содржини"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Се синхронизираат аудиовизуелните содржини…"</string> <string name="add" msgid="2894574044585549298">"Додај"</string> <string name="deselect" msgid="4297825044827769490">"Поништи го изборот"</string> <string name="deselected" msgid="8488133193326208475">"Изборот е поништен"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Нема албуми"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Прикажи ги избраните"</string> <string name="picker_photos" msgid="7415035516411087392">"Фотографии"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Албуми"</string> <string name="picker_preview" msgid="6257414886055861039">"Преглед"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Префрли на работен профил"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Префрли на личен профил"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Префрлете се на работен профил"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Префрлете се на личен профил"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Блокирано од администраторот"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Не е дозволено пристапување до работни податоци од лична апликација"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Не е дозволено пристапување до лични податоци од работна апликација"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ставка}one{<xliff:g id="COUNT_1">^1</xliff:g> ставка}other{<xliff:g id="COUNT_1">^1</xliff:g> ставки}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Додај (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Дозволи (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ниедна"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Преземања"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Омилени"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Проблем со пуштањето видео"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Проверете ја интернет-врската и обидете се повторно"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Повторно"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Аудиовизуелните содржини во облак сега се достапни од <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"не е избрано"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Вашите избрани аудиовизуелни содржини се подготвуваат"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Подготвени: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> од <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Бекап од фотографиите сега е влучен"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Откажи"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Сега се опфатени фотографии од бекап"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Можете да изберете фотографии од сметката <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> на <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Сметката на <xliff:g id="APP_NAME">%1$s</xliff:g> е ажурирана"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Фотографиите од <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> сега се вклучени овде"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Изберете апликација"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Изберете сметка"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Променете ја сметката"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Се вчитуваат сите ваши фотографии"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Да се дозволи <xliff:g id="APP_NAME_0">^1</xliff:g> да ја измени аудиодатотекава?}one{Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> аудиодатотека?}other{Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> аудиодатотеки?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Се изменува аудиодатотеката…}one{Се изменуваат <xliff:g id="COUNT">^1</xliff:g> аудиодатотека…}other{Се изменуваат <xliff:g id="COUNT">^1</xliff:g> аудиодатотеки…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Да се дозволи <xliff:g id="APP_NAME_0">^1</xliff:g> да го измени видеово?}one{Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> видео?}other{Да се дозволи <xliff:g id="APP_NAME_1">^1</xliff:g> да измени <xliff:g id="COUNT">^2</xliff:g> видеа?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Безбедносна заштита"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Предупредувања за матичното транскодирање"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Напредок на матичното транскодирање"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Обидете се повторно подоцна. Вашите фотографии ќе бидат достапни откако ќе се реши проблемот."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Некои фотографии не може да се вчитаат"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Сфатив"</string> </resources> diff --git a/res/values-ml/strings.xml b/res/values-ml/strings.xml index 42b9c439c..5def22bd1 100644 --- a/res/values-ml/strings.xml +++ b/res/values-ml/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"മീഡിയ"</string> <string name="storage_description" msgid="4081716890357580107">"ലോക്കൽ സ്റ്റോറേജ്"</string> - <string name="app_label" msgid="9035307001052716210">"മീഡിയ സ്റ്റോറേജ്"</string> - <string name="picker_app_label" msgid="4254039089502164761">"മീഡിയ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"മീഡിയ പിക്കർ"</string> <string name="artist_label" msgid="8105600993099120273">"ആർട്ടിസ്റ്റ്"</string> <string name="unknown" msgid="2059049215682829375">"അജ്ഞാതം"</string> <string name="root_images" msgid="5861633549189045666">"ചിത്രങ്ങൾ"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ക്ലൗഡ് മീഡിയ ആപ്പിൽ നിന്ന്"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ഒന്നുമില്ല"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ക്ലൗഡ് മീഡിയ ആപ്പ് ഇപ്പോൾ മാറ്റാനാകുന്നില്ല."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"മീഡിയാ പിക്കർ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"മീഡിയാ പിക്കർ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"മീഡിയാ സമന്വയിപ്പിക്കുന്നു…"</string> <string name="add" msgid="2894574044585549298">"ചേർക്കുക"</string> <string name="deselect" msgid="4297825044827769490">"തിരഞ്ഞെടുത്തത് മാറ്റുക"</string> <string name="deselected" msgid="8488133193326208475">"തിരഞ്ഞെടുത്തത് മാറ്റി"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ആൽബങ്ങളൊന്നുമില്ല"</string> <string name="picker_view_selected" msgid="2266031384396143883">"തിരഞ്ഞെടുത്തത് കാണുക"</string> <string name="picker_photos" msgid="7415035516411087392">"ഫോട്ടോകൾ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ആൽബങ്ങൾ"</string> <string name="picker_preview" msgid="6257414886055861039">"പ്രിവ്യു"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ഔദ്യോഗിക പ്രൊഫൈലിലേക്ക് മാറുക"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ഇനം}other{<xliff:g id="COUNT_1">^1</xliff:g> ഇനങ്ങൾ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) ചേർക്കുക"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"(<xliff:g id="COUNT">^1</xliff:g>) എണ്ണത്തെ അനുവദിക്കുക"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ഒന്നും അനുവദിക്കരുത്"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ക്യാമറ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ഡൗൺലോഡുകൾ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"പ്രിയപ്പെട്ടവ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"വീഡിയോ പ്ലേ ചെയ്യുന്നതിൽ പ്രശ്നം"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"നിങ്ങളുടെ ഇന്റർനെറ്റ് കണക്ഷൻ പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"വീണ്ടും ശ്രമിക്കുക"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ഇപ്പോൾ <xliff:g id="PKG_NAME">%1$s</xliff:g> എന്നതിൽ നിന്ന് ക്ലൗഡ് മീഡിയ ലഭ്യമാണ്"</string> <string name="not_selected" msgid="2244008151669896758">"തിരഞ്ഞെടുത്തിട്ടില്ല"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"നിങ്ങൾ തിരഞ്ഞെടുത്ത മീഡിയ തയ്യാറാക്കുന്നു"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>-ൽ <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> എണ്ണം തയ്യാറാണ്"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"റദ്ദാക്കുക"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ബാക്കപ്പ് ചെയ്ത ഫോട്ടോകൾ ഇപ്പോൾ ഉൾപ്പെടുത്തിയിരിക്കുന്നു"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"നിങ്ങൾക്ക് <xliff:g id="APP_NAME">%1$s</xliff:g> അക്കൗണ്ടിൽ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> നിന്ന് ഫോട്ടോകൾ തിരഞ്ഞെടുക്കാം"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> അക്കൗണ്ട് അപ്ഡേറ്റ് ചെയ്തു"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"സുരക്ഷാ പരിരക്ഷ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"നേറ്റീവ് ട്രാൻസ്കോഡ് മുന്നറിയിപ്പുകൾ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"നേറ്റീവ് ട്രാൻസ്കോഡ് പുരോഗതി"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"പിന്നീട് വീണ്ടും ശ്രമിക്കുക. പ്രശ്നം പരിഹരിച്ച് കഴിഞ്ഞ് നിങ്ങളുടെ ഫോട്ടോകൾ ലഭ്യമാകും."</string> + <string name="dialog_error_title" msgid="636349284077820636">"ചില ഫോട്ടോകൾ ലോഡ് ചെയ്യാനാകുന്നില്ല"</string> + <string name="dialog_button_text" msgid="351366485240852280">"മനസ്സിലായി"</string> </resources> diff --git a/res/values-mn/strings.xml b/res/values-mn/strings.xml index 3dc2d7d83..e1a524647 100644 --- a/res/values-mn/strings.xml +++ b/res/values-mn/strings.xml @@ -18,12 +18,11 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Медиа"</string> <string name="storage_description" msgid="4081716890357580107">"Дотоод сан"</string> - <string name="app_label" msgid="9035307001052716210">"Медиа санах ой"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Медиа"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Медиа сонгогч"</string> <string name="artist_label" msgid="8105600993099120273">"Уран бүтээлч"</string> <string name="unknown" msgid="2059049215682829375">"Тодорхойгүй"</string> <string name="root_images" msgid="5861633549189045666">"Зураг"</string> - <string name="root_videos" msgid="8792703517064649453">"Бичлэг"</string> + <string name="root_videos" msgid="8792703517064649453">"Видео"</string> <string name="root_audio" msgid="3505830755201326018">"Аудио"</string> <string name="root_documents" msgid="3829103301363849237">"Документ"</string> <string name="permission_required" msgid="1460820436132943754">"Энэ зүйлийг өөрчлөх эсвэл устгахад зөвшөөрөл шаардлагатай."</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Үүлэн медиа апп"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Үүлэн медиа апп"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Үүлэн медиа апп"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Апп эсвэл вебсайт танаас зураг эсвэл видео сонгохыг хүсэх үед таны үүлэн медиадаа хандана уу"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Апп эсвэл вебсайт танаас зураг эсвэл видео сонгохыг хүсвэл үүлэн медиадаа хандана уу"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Дараахаас үүлэн медиад хандах"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Байхгүй"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Энэ удаад үүлэн медиа аппыг өөрчилж чадсангүй."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Медиа сонгогч"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Медиа сонгогч"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Медиаг синк хийж байна…"</string> <string name="add" msgid="2894574044585549298">"Нэмэх"</string> <string name="deselect" msgid="4297825044827769490">"Сонголтыг цуцлах"</string> <string name="deselected" msgid="8488133193326208475">"Сонголтыг цуцалсан"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Цомог алга"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Сонгосныг харах"</string> <string name="picker_photos" msgid="7415035516411087392">"Зураг"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Цомог"</string> <string name="picker_preview" msgid="6257414886055861039">"Урьдчилан үзэх"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Ажлын профайл руу сэлгэх"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> зүйл}other{<xliff:g id="COUNT_1">^1</xliff:g> зүйл}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Нэмэх (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Зөвшөөрөх (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Юуг ч бүү зөвшөөр"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камер"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Таталтууд"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Дуртай зүйлс"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Видеог тоглуулахад асуудал гарлаа"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Интернэт холболтоо шалгаад, дахин оролдоно уу"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Дахин оролдох"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Үүлэн медиаг одоо <xliff:g id="PKG_NAME">%1$s</xliff:g>-с авах боломжтой боллоо"</string> <string name="not_selected" msgid="2244008151669896758">"сонгоогүй"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Таны сонгосон медиаг бэлтгэж байна"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>-с <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> бэлэн"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Цуцлах"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Одоо хуулбарласан зургийг багтаасан"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Та <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> бүртгэлээс зураг сонгох боломжтой"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> бүртгэлийг шинэчилсэн"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Апп сонгох"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Бүртгэл сонгох"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Бүртгэл өөрчлөх"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Таны бүх зургийг авч байна"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>-д энэ аудио файлыг өөрчлөхийг зөвшөөрөх үү?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>-д <xliff:g id="COUNT">^2</xliff:g> аудио файлыг өөрчлөхийг зөвшөөрөх үү?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Аудио файлыг өөрчилж байна…}other{<xliff:g id="COUNT">^1</xliff:g> аудио файлыг өөрчилж байна…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g>-д энэ видеог өөрчлөхийг зөвшөөрөх үү?}other{<xliff:g id="APP_NAME_1">^1</xliff:g>-д <xliff:g id="COUNT">^2</xliff:g> видеог өөрчлөхийг зөвшөөрөх үү?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Аюулгүй байдлын хамгаалалт"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Уугуул хөрвүүлгийн сэрэмжлүүлэг"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Уугуул хөрвүүлгийн явц"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Дараа дахин оролдоно уу. Асуудлыг шийдвэрлэсний дараа таны зургууд боломжтой болно."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Зарим зургийг ачаалах боломжгүй"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Ойлголоо"</string> </resources> diff --git a/res/values-mr/strings.xml b/res/values-mr/strings.xml index 0b68570a5..184f72bdc 100644 --- a/res/values-mr/strings.xml +++ b/res/values-mr/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"मीडिया"</string> <string name="storage_description" msgid="4081716890357580107">"स्थानिक स्टोरेज"</string> - <string name="app_label" msgid="9035307001052716210">"मीडिया स्टोरेज"</string> - <string name="picker_app_label" msgid="4254039089502164761">"मीडिया"</string> + <string name="picker_app_label" msgid="1195424381053599122">"मीडिया पिकर"</string> <string name="artist_label" msgid="8105600993099120273">"कलाकार"</string> <string name="unknown" msgid="2059049215682829375">"अज्ञात"</string> <string name="root_images" msgid="5861633549189045666">"इमेज"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"येथून क्लाउड मीडिया अॅक्सेस करा"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"काहीही नाही"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"क्लाउड मीडिया अॅप या क्षणी बदलू शकत नाही."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"मीडिया पिकर"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"मीडिया पिकर"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"मीडिया सिंक करत आहे…"</string> <string name="add" msgid="2894574044585549298">"जोडा"</string> <string name="deselect" msgid="4297825044827769490">"निवड रद्द करा"</string> <string name="deselected" msgid="8488133193326208475">"निवड रद्द केली आहे"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"कोणतेही अल्बम नाहीत"</string> <string name="picker_view_selected" msgid="2266031384396143883">"निवडलेले पहा"</string> <string name="picker_photos" msgid="7415035516411087392">"फोटो"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"अल्बम"</string> <string name="picker_preview" msgid="6257414886055861039">"पूर्वावलोकन"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ऑफिसवर स्विच करा"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> आयटम}other{<xliff:g id="COUNT_1">^1</xliff:g> आयटम}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) जोडा"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"(<xliff:g id="COUNT">^1</xliff:g>) ला अनुमती द्या"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"काहीही नाही याला अनुमती द्या"</string> <string name="picker_category_camera" msgid="4857367052026843664">"कॅमेरा"</string> <string name="picker_category_downloads" msgid="793866660287361900">"डाउनलोड"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"आवडते"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"व्हिडिओ प्ले करण्यात समस्या आली"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"तुमचे इंटरनेट कनेक्शन तपासा आणि पुन्हा प्रयत्न करा"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"पुन्हा प्रयत्न करा"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"आता <xliff:g id="PKG_NAME">%1$s</xliff:g> कडून क्लाउड मीडिया उपलब्ध आहे"</string> <string name="not_selected" msgid="2244008151669896758">"निवडला नाही"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"तुमचा निवडलेला मीडिया तयार करत आहे"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> पैकी <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> तयार"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"रद्द करा"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"बॅकअप घेतलेल्या फोटोचा आता समावेश केला आहे"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"तुम्ही <xliff:g id="APP_NAME">%1$s</xliff:g> खात्याच्या <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> वरून फोटो निवडू शकता"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> खाते अपडेट केले"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ॲप निवडा"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"खाते निवडा"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"खाते बदला"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"तुमचे सर्व फोटो मिळवत आहे"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ला या ऑडिओ फाइलमध्ये फेरबदल करण्याची अनुमती द्यायची आहे का?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> ला <xliff:g id="COUNT">^2</xliff:g> ऑडिओ फाइलमध्ये फेरबदल करण्याची अनुमती द्यायची आहे का?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ऑडिओ फाइलमध्ये फेरबदल करत आहे…}other{<xliff:g id="COUNT">^1</xliff:g> ऑडिओ फाइलमध्ये फेरबदल करत आहे…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ला या व्हिडिओमध्ये फेरबदल करण्याची अनुमती द्यायची आहे का?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> ला <xliff:g id="COUNT">^2</xliff:g> व्हिडिओमध्ये फेरबदल करण्याची अनुमती द्यायची आहे का?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"सुरक्षितता संरक्षण"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"मूळ ट्रान्सकोड सूचना"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"मूळ ट्रान्सकोड प्रगती"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"नंतर पुन्हा प्रयत्न करा. समस्येचे निराकरण झाल्यावर तुमचे फोटो उपलब्ध होतील."</string> + <string name="dialog_error_title" msgid="636349284077820636">"काही फोटो लोड करू शकत नाही"</string> + <string name="dialog_button_text" msgid="351366485240852280">"समजले"</string> </resources> diff --git a/res/values-ms/strings.xml b/res/values-ms/strings.xml index a285a4dbf..9f4885070 100644 --- a/res/values-ms/strings.xml +++ b/res/values-ms/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Storan setempat"</string> - <string name="app_label" msgid="9035307001052716210">"Storan Media"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Pemilih Media"</string> <string name="artist_label" msgid="8105600993099120273">"Artis"</string> <string name="unknown" msgid="2059049215682829375">"Tidak diketahui"</string> <string name="root_images" msgid="5861633549189045666">"Imej"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Akses media awan daripada"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Tiada"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Tidak dapat menukar apl media awan pada masa ini."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Pemilih media"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Pemilih media"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Menyegerakkan media…"</string> <string name="add" msgid="2894574044585549298">"Tambah"</string> <string name="deselect" msgid="4297825044827769490">"Nyahpilih"</string> <string name="deselected" msgid="8488133193326208475">"Dinyahpilih"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Tiada album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Lihat terpilih"</string> <string name="picker_photos" msgid="7415035516411087392">"Foto"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Pratonton"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Beralih kepada kerja"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> item}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Tambah (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Benarkan (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Tiada yang dibenarkan"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Muat turun"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Kegemaran"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Berlaku masalah semasa memainkan video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Semak sambungan Internet anda, kemudian cuba lagi"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Cuba lagi"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Media awan kini tersedia daripada <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"tidak dipilih"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Menyediakan media pilihan anda"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> daripada <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> sudah bersedia"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Batal"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Foto yang disandarkan kini disertakan"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Anda boleh memilih foto daripada akaun <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Akaun <xliff:g id="APP_NAME">%1$s</xliff:g> dikemas kini"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Pilih apl"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Pilih akaun"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Tukar akaun"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Mendapatkan semua foto anda"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Benarkan <xliff:g id="APP_NAME_0">^1</xliff:g> mengubah suai fail audio ini?}other{Benarkan <xliff:g id="APP_NAME_1">^1</xliff:g> mengubah suai <xliff:g id="COUNT">^2</xliff:g> fail audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Mengubah suai fail audio…}other{Mengubah suai <xliff:g id="COUNT">^1</xliff:g> fail audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Benarkan <xliff:g id="APP_NAME_0">^1</xliff:g> mengubah suai video ini?}other{Benarkan <xliff:g id="APP_NAME_1">^1</xliff:g> mengubah suai <xliff:g id="COUNT">^2</xliff:g> video?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Perlindungan keselamatan"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Amaran Transkod Asal"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Kemajuan Transkod Asal"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Cuba sebentar lagi. Foto anda akan tersedia selepas masalah ini diselesaikan."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Tidak dapat memuatkan beberapa foto"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-my/strings.xml b/res/values-my/strings.xml index 84e1513ea..322eb0a33 100644 --- a/res/values-my/strings.xml +++ b/res/values-my/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"မီဒီယာ"</string> <string name="storage_description" msgid="4081716890357580107">"စက်တွင်း သိုလှောင်ခန်း"</string> - <string name="app_label" msgid="9035307001052716210">"မီဒီယာ သိုလှောင်ခန်း"</string> - <string name="picker_app_label" msgid="4254039089502164761">"မီဒီယာ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"မီဒီယာရွေးရန်"</string> <string name="artist_label" msgid="8105600993099120273">"အနုပညာရှင်"</string> <string name="unknown" msgid="2059049215682829375">"အမျိုးအမည်မသိ"</string> <string name="root_images" msgid="5861633549189045666">"ပုံများ"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Cloud မီဒီယာအက်ပ်"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Cloud မီဒီယာအက်ပ်"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Cloud မီဒီယာအက်ပ်"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"အက်ပ် (သို့) ဝဘ်ဆိုက်က သင့်အား ဓာတ်ပုံ (သို့) ဗီဒီယိုများ ရွေးခိုင်းသောအခါ သင်၏ cloud မီဒီယာကို ဝင်ပါ"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"အက်ပ် (သို့) ဝဘ်ဆိုက်က သင့်အား ဓာတ်ပုံ (သို့) ဗီဒီယိုများ ရွေးခိုင်းသောအခါ သင်၏ cloud မီဒီယာကို ဝင်သည်"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Cloud မီဒီယာကို ဤနေရာမှ ဝင်သုံးရန်"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"မရှိ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"လောလောဆယ် cloud မီဒီယာ အက်ပ်ကို ပြောင်း၍မရပါ။"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"မီဒီယာရွေးခြင်း"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"မီဒီယာရွေးခြင်း"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"မီဒီယာကို စင့်ခ်လုပ်နေသည်…"</string> <string name="add" msgid="2894574044585549298">"ထည့်ရန်"</string> <string name="deselect" msgid="4297825044827769490">"မရွေးပါနှင့်"</string> <string name="deselected" msgid="8488133193326208475">"ရွေးချယ်မထားပါ"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"အယ်လ်ဘမ်များ မရှိပါ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ပြသမှုကို ရွေးချယ်ထားသည်"</string> <string name="picker_photos" msgid="7415035516411087392">"ဓာတ်ပုံများ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"အယ်လ်ဘမ်များ"</string> <string name="picker_preview" msgid="6257414886055861039">"အစမ်းကြည့်ရှုခြင်း"</string> <string name="picker_work_profile" msgid="2083221066869141576">"အလုပ်သို့ ပြောင်းပါ"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{ဖိုင် <xliff:g id="COUNT_0">^1</xliff:g> ခု}other{ဖိုင် <xliff:g id="COUNT_1">^1</xliff:g> ခု}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) ခု ထည့်ရန်"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"(<xliff:g id="COUNT">^1</xliff:g>) ခု ခွင့်ပြုရန်"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"သုည ခွင့်ပြုရန်"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ကင်မရာ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ဒေါင်းလုဒ်များ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"စိတ်ကြိုက်များ"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ဗီဒီယိုဖွင့်ရာတွင် ပြဿနာရှိသည်"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"သင်၏ အင်တာနက် ချိတ်ဆက်မှုကို စစ်ဆေးပြီး ထပ်စမ်းကြည့်ပါ"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ထပ်စမ်းကြည့်ရန်"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"<xliff:g id="PKG_NAME">%1$s</xliff:g> တွင် Cloud မီဒီယာကို ယခု ရနိုင်ပြီ"</string> <string name="not_selected" msgid="2244008151669896758">"ရွေးချယ်မထားပါ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"သင်ရွေးထားသော မီဒီယာကို ပြင်ဆင်နေသည်"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> အနက် <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> အသင့်ဖြစ်ပြီ"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"မလုပ်တော့"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"အရန်သိမ်းထားသော ဓာတ်ပုံများ ယခုထည့်သွင်းထားပြီ"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> အကောင့်မှ ဓာတ်ပုံများ ရွေးနိုင်သည်"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> အကောင့် <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> မှ ဓာတ်ပုံများ ရွေးနိုင်သည်"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> အကောင့် အပ်ဒိတ်လုပ်လိုက်သည်"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"<xliff:g id="USER_ACCOUNT">%1$s</xliff:g> မှ ဓာတ်ပုံများကို ဤနေရာတွင် ယခုထည့်သွင်းထားပါပြီ"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"cloud မီဒီယာအက်ပ် ရွေးရန်"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"အက်ပ်ရွေးရန်"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"အကောင့်ရွေးရန်"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"အကောင့်ပြောင်းရန်"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"သင့်ဓာတ်ပုံအားလုံးကို ရယူနေသည်"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ကို ဤအသံဖိုင် ပြင်ဆင်ခွင့်ပြုမလား။}other{<xliff:g id="APP_NAME_1">^1</xliff:g> ကို အသံဖိုင် <xliff:g id="COUNT">^2</xliff:g> ဖိုင် ပြင်ဆင်ခွင့်ပြုမလား။}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{အသံဖိုင်ကို ပြင်ဆင်နေသည်…}other{အသံဖိုင် <xliff:g id="COUNT">^1</xliff:g> ခုကို ပြင်ဆင်နေသည်…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ကို ဤဗီဒီယို ပြင်ဆင်ခွင့်ပြုမလား။}other{<xliff:g id="APP_NAME_1">^1</xliff:g> ကို ဗီဒီယို <xliff:g id="COUNT">^2</xliff:g> ခု ပြင်ဆင်ခွင့်ပြုမလား။}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"လုံခြုံရေး ကာကွယ်မှု"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"မူရင်းမီဒီယာကုဒ်ပြောင်းသည့် သတိပေးချက်များ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"မူရင်းမီဒီယာကုဒ်ပြောင်းသည့် အခြေအနေ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"နောက်မှထပ်စမ်းပါ။ ပြဿနာကို ဖြေရှင်းပြီးသည့်အခါ သင့်ဓာတ်ပုံများကို ရနိုင်မည်။"</string> + <string name="dialog_error_title" msgid="636349284077820636">"ဓာတ်ပုံအချို့ကို ဖွင့်၍ မရပါ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"နားလည်ပြီ"</string> </resources> diff --git a/res/values-nb/strings.xml b/res/values-nb/strings.xml index 2fa7c4330..060945b56 100644 --- a/res/values-nb/strings.xml +++ b/res/values-nb/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Medier"</string> <string name="storage_description" msgid="4081716890357580107">"Lokal lagring"</string> - <string name="app_label" msgid="9035307001052716210">"Medielagring"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Medier"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Medievelger"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Ukjent"</string> <string name="root_images" msgid="5861633549189045666">"Bilder"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Åpne skymedier fra"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ingen"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Kunne ikke endre skymedieappen akkurat nå."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Medievelger"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Medievelger"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synkroniserer medieinnholdet …"</string> <string name="add" msgid="2894574044585549298">"Legg til"</string> <string name="deselect" msgid="4297825044827769490">"Fjern merking"</string> <string name="deselected" msgid="8488133193326208475">"Ikke valgt"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ingen album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Vis valgte"</string> <string name="picker_photos" msgid="7415035516411087392">"Bilder"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Forhåndsvisning"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Bytt til jobbprofilen"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}other{<xliff:g id="COUNT_1">^1</xliff:g> elementer}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Legg til (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Tillat (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Tillat ingen"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Nedlastinger"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritter"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problem med avspilling av videoen"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Sjekk internettilkoblingen og prøv på nytt"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Prøv på nytt"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Skymedier er nå tilgjengelige fra <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"ikke valgt"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Klargjør det valgte medieinnholdet"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> av <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> er klare"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Avbryt"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Nå er sikkerhetskopierte bilder inkludert"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Du kan velge bilder fra <xliff:g id="APP_NAME">%1$s</xliff:g>-kontoen <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-kontoen er oppdatert"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Velg app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Velg konto"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Bytt konto"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Laster inn alle bildene dine"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vil du tillate at <xliff:g id="APP_NAME_0">^1</xliff:g> endrer denne lydfilen?}other{Vil du tillate at <xliff:g id="APP_NAME_1">^1</xliff:g> endrer <xliff:g id="COUNT">^2</xliff:g> lydfiler?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Endrer lydfilen …}other{Endrer <xliff:g id="COUNT">^1</xliff:g> lydfiler …}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vil du tillate at <xliff:g id="APP_NAME_0">^1</xliff:g> endrer denne videoen?}other{Vil du tillate at <xliff:g id="APP_NAME_1">^1</xliff:g> endrer <xliff:g id="COUNT">^2</xliff:g> videoer?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Beskyttelse"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Integrerte omkodingsvarsler"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Integrert omkodingsfremdrift"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Prøv på nytt senere. Bildene dine blir tilgjengelige når problemet er løst."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Noen bilder kan ikke lastes inn"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Greit"</string> </resources> diff --git a/res/values-ne/strings.xml b/res/values-ne/strings.xml index ec0156430..5aab7f02a 100644 --- a/res/values-ne/strings.xml +++ b/res/values-ne/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"मिडिया"</string> <string name="storage_description" msgid="4081716890357580107">"स्थानीय भण्डारण"</string> - <string name="app_label" msgid="9035307001052716210">"मिडिया भण्डारण"</string> - <string name="picker_app_label" msgid="4254039089502164761">"मिडिया"</string> + <string name="picker_app_label" msgid="1195424381053599122">"मिडिया पिकर"</string> <string name="artist_label" msgid="8105600993099120273">"कलाकार"</string> <string name="unknown" msgid="2059049215682829375">"अज्ञात"</string> <string name="root_images" msgid="5861633549189045666">"फोटो"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"यसबाट क्लाउड मिडिया प्रयोग गर्नुहोस्"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"कुनै पनि होइन"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"अहिले क्लाउड मिडिया एप परिवर्तन गर्न सकिएन।"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"मिडिया पिकर"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"मिडिया पिकर"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"मिडिया सिंक हुँदै छ…"</string> <string name="add" msgid="2894574044585549298">"हाल्नुहोस्"</string> <string name="deselect" msgid="4297825044827769490">"चयन रद्द गर्नुहोस्"</string> <string name="deselected" msgid="8488133193326208475">"चयन रद्द गरियो"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"कुनै पनि एल्बम छैन"</string> <string name="picker_view_selected" msgid="2266031384396143883">"चयन गरिएका सामग्री हेर्नुहोस्"</string> <string name="picker_photos" msgid="7415035516411087392">"फोटोहरू"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"एल्बमहरू"</string> <string name="picker_preview" msgid="6257414886055861039">"प्रिभ्यू"</string> <string name="picker_work_profile" msgid="2083221066869141576">"कार्य प्रोफाइल प्रयोग गर्नुहोस्"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> वटा वस्तु}other{<xliff:g id="COUNT_1">^1</xliff:g> वटा वस्तु}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"थप्नुहोस् (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"अनुमति दिनुहोस् (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"कुनै पनि फोटो प्रयोग गर्न नदिनुहोस्"</string> <string name="picker_category_camera" msgid="4857367052026843664">"क्यामेरा"</string> <string name="picker_category_downloads" msgid="793866660287361900">"डाउनलोडहरू"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"मन पर्ने कुराहरू"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"भिडियो प्ले गर्दा समस्या भयो"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"इन्टरनेट जाँच्नुहोस् र फेरि प्रयास गर्नुहोस्"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"फेरि प्रयास गर्नुहोस्"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"क्लाउड मिडिया अब <xliff:g id="PKG_NAME">%1$s</xliff:g> मा उपलब्ध छ"</string> <string name="not_selected" msgid="2244008151669896758">"चयन गरिएको छैन"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"तपाईंले चयन गर्नुभएको मिडिया तयार गरिँदै छ"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> मध्ये <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> वटा फोटो तयार छन्"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"रद्द गर्नुहोस्"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"अब ब्याकअप गरिएका फोटोहरू समावेश गरिएका छन्"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"तपाईं <xliff:g id="APP_NAME">%1$s</xliff:g> मा <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> खाता प्रयोग गरी राखिएका फोटोहरू चयन गर्न सक्नुहुन्छ"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> खाता अपडेट गरियो"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"एप छनौट गर्नुहोस्"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"खाता छनौट गर्नुहोस्"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"खाता बदल्नुहोस्"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"तपाईंका सबै फोटोहरू प्राप्त गरिँदै छन्"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> लाई यो अडियो फाइल परिमार्जन गर्न दिने हो?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> लाई <xliff:g id="COUNT">^2</xliff:g> वटा अडियो फाइल परिमार्जन गर्न दिने हो?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{अडियो फाइल परिमार्जन गरिँदै छ…}other{<xliff:g id="COUNT">^1</xliff:g> वटा अडियो फाइल परिमार्जन गरिँदै छन्…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> लाई यो भिडियो परिमार्जन गर्न दिने हो?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> लाई <xliff:g id="COUNT">^2</xliff:g> वटा भिडियो परिमार्जन गर्न दिने हो?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"सेफ्टी प्रोटेक्सन"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"नेटिभ ट्रान्स्कोड अलर्ट"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"नेटिभ ट्रान्स्कोड प्रोग्रेस"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"पछि फेरि प्रयास गर्नुहोस्। समस्या समाधान हुनेबित्तिकै तपाईंका फोटो उपलब्ध हुने छन्।"</string> + <string name="dialog_error_title" msgid="636349284077820636">"केही फोटोहरू लोड गर्न सकिँदैन"</string> + <string name="dialog_button_text" msgid="351366485240852280">"बुझेँ"</string> </resources> diff --git a/res/values-night-v31/styles.xml b/res/values-night-v31/styles.xml index 2a936f09a..8e58ad7ab 100644 --- a/res/values-night-v31/styles.xml +++ b/res/values-night-v31/styles.xml @@ -14,7 +14,8 @@ limitations under the License. --> -<resources xmlns:android="http://schemas.android.com/apk/res/android"> +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> <style name="PickerMaterialTheme" parent="@style/Theme.Material3.DayNight.NoActionBar"> <item name="materialAlertDialogTheme">@style/ProfileDialogTheme</item> @@ -40,6 +41,8 @@ <item name="pickerBannerPrimaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerSecondaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerButtonTextColor">@android:color/system_accent1_300</item> + <item name="categoryDefaultThumbnailColor">?attr/colorOnSurfaceVariant</item> + <item name="categoryDefaultThumbnailCircleColor">?attr/colorSurfaceVariant</item> </style> </resources> diff --git a/res/values-night/styles.xml b/res/values-night/styles.xml index 72f234d1a..7a16b59e1 100644 --- a/res/values-night/styles.xml +++ b/res/values-night/styles.xml @@ -14,7 +14,8 @@ limitations under the License. --> -<resources xmlns:android="http://schemas.android.com/apk/res/android"> +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> <style name="PickerDialogTheme" parent="@android:style/Theme.DeviceDefault.Dialog.Alert"> @@ -35,7 +36,7 @@ <item name="android:alertDialogTheme">@style/AlertDialogTheme</item> </style> - <style name="PickerMaterialTheme" parent="@style/Theme.MaterialComponents.DayNight.NoActionBar"> + <style name="PickerMaterialTheme" parent="@style/Theme.Material3.DayNight.NoActionBar"> <item name="materialAlertDialogTheme">@style/ProfileDialogTheme</item> <item name="pickerDragBarColor">#686868</item> <item name="pickerHighlightColor">?android:attr/colorAccent</item> @@ -59,6 +60,8 @@ <item name="pickerBannerPrimaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerSecondaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerButtonTextColor">?android:attr/colorAccent</item> + <item name="categoryDefaultThumbnailColor">?attr/colorOnSurfaceVariant</item> + <item name="categoryDefaultThumbnailCircleColor">?attr/colorSurfaceVariant</item> </style> </resources> diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml index 5c685ae0a..df6961086 100644 --- a/res/values-nl/strings.xml +++ b/res/values-nl/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Lokale opslag"</string> - <string name="app_label" msgid="9035307001052716210">"Mediaopslag"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Mediakiezer"</string> <string name="artist_label" msgid="8105600993099120273">"Artiest"</string> <string name="unknown" msgid="2059049215682829375">"Onbekend"</string> <string name="root_images" msgid="5861633549189045666">"Afbeeldingen"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Cloudmedia openen vanuit"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Geen"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Cloudmedia-app kan nu niet worden gewijzigd."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Mediakiezer"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Mediakiezer"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Media synchroniseren…"</string> <string name="add" msgid="2894574044585549298">"Toevoegen"</string> <string name="deselect" msgid="4297825044827769490">"Deselecteren"</string> <string name="deselected" msgid="8488133193326208475">"Gedeselecteerd"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Geen albums"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Selectie bekijken"</string> <string name="picker_photos" msgid="7415035516411087392">"Foto\'s"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albums"</string> <string name="picker_preview" msgid="6257414886055861039">"Voorbeeld"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Overschakelen naar werkprofiel"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> items}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Toevoegen (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Toestaan (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Geen toestaan"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favorieten"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Probleem bij video afspelen"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Check de internetverbinding en probeer het opnieuw"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Opnieuw proberen"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloudmedia nu beschikbaar van <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"niet geselecteerd"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Je geselecteerde media voorbereiden"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> van <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> klaar"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Annuleren"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Nu ook met foto\'s waarvan een back-up is gemaakt"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Je kunt foto\'s selecteren uit het <xliff:g id="APP_NAME">%1$s</xliff:g>-account <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Account voor <xliff:g id="APP_NAME">%1$s</xliff:g> geüpdatet"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"App selecteren"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Account kiezen"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Account wijzigen"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Al je foto\'s ophalen"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> toestaan dit audiobestand aan te passen?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> toestaan <xliff:g id="COUNT">^2</xliff:g> audiobestanden aan te passen?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Audiobestand aanpassen…}other{<xliff:g id="COUNT">^1</xliff:g> audiobestanden aanpassen…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> toestaan deze video aan te passen?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> toestaan <xliff:g id="COUNT">^2</xliff:g> video\'s aan te passen?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Beveiliging"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Meldingen voor native transcodering"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Voortgang van native transcodering"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Probeer het later opnieuw. Je foto\'s komen beschikbaar nadat het probleem is opgelost."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Kan bepaalde foto\'s niet laden"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-or/strings.xml b/res/values-or/strings.xml index ff2c2b8fe..853386425 100644 --- a/res/values-or/strings.xml +++ b/res/values-or/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"ମିଡିଆ"</string> <string name="storage_description" msgid="4081716890357580107">"ଲୋକାଲ୍ ଷ୍ଟୋରେଜ୍"</string> - <string name="app_label" msgid="9035307001052716210">"ମିଡିଆ ଷ୍ଟୋରେଜ୍"</string> - <string name="picker_app_label" msgid="4254039089502164761">"ମିଡିଆ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"ମିଡିଆ ପିକର"</string> <string name="artist_label" msgid="8105600993099120273">"କଳାକାର"</string> <string name="unknown" msgid="2059049215682829375">"ଅଜଣା"</string> <string name="root_images" msgid="5861633549189045666">"ଇମେଜ୍"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ଏଠାରୁ କ୍ଲାଉଡ ମିଡିଆକୁ ଆକ୍ସେସ କରନ୍ତୁ"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"କିଛି ନାହିଁ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ଏହି ସମୟରେ କ୍ଲାଉଡ ମିଡିଆ ଆପ ପରିବର୍ତ୍ତନ ହେଲା ନାହିଁ।"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"ମିଡିଆ ପିକର"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"ମିଡିଆ ପିକର"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"ମିଡିଆ ସିଙ୍କ କରାଯାଉଛି…"</string> <string name="add" msgid="2894574044585549298">"ଯୋଗ କରନ୍ତୁ"</string> <string name="deselect" msgid="4297825044827769490">"ଅଚୟନ କରନ୍ତୁ"</string> <string name="deselected" msgid="8488133193326208475">"ଅଚୟନ କରାଯାଇଛି"</string> @@ -56,14 +58,16 @@ <string name="picker_photos_empty_message" msgid="5980619500554575558">"କୌଣସି ଫଟୋ କିମ୍ବା ଭିଡିଓ ନାହିଁ"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"କୌଣସି ସମର୍ଥିତ ଫଟୋ କିମ୍ବା ଭିଡିଓ ନାହିଁ"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"କୌଣସି ଆଲବମ ନାହିଁ"</string> - <string name="picker_view_selected" msgid="2266031384396143883">"ଚୟନିତଗୁଡ଼ିକୁ ଦେଖନ୍ତୁ"</string> + <string name="picker_view_selected" msgid="2266031384396143883">"ଚୟନିତଗୁଡ଼ିକୁ ଭ୍ୟୁ କରନ୍ତୁ"</string> <string name="picker_photos" msgid="7415035516411087392">"ଫଟୋ"</string> - <string name="picker_albums" msgid="4822511902115299142">"ଆଲବମ୍"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> + <string name="picker_albums" msgid="4822511902115299142">"ଆଲବମ"</string> <string name="picker_preview" msgid="6257414886055861039">"ପ୍ରିଭ୍ୟୁ"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"ୱାର୍କକୁ ସ୍ୱିଚ୍ କରନ୍ତୁ"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"ବ୍ୟକ୍ତିଗତକୁ ସ୍ୱିଚ୍ କରନ୍ତୁ"</string> - <string name="picker_profile_admin_title" msgid="4172022376418293777">"ଆପଣଙ୍କ ଆଡମିନଙ୍କ ଦ୍ୱାରା ବ୍ଲକ୍ କରାଯାଇଛି"</string> - <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"କୌଣସି ବ୍ୟକ୍ତିଗତ ଆପରୁ ୱାର୍କ ଡାଟାକୁ ଆକ୍ସେସ୍ କରିବା ପାଇଁ ଅନୁମତି ଦିଆଯାଇନାହିଁ"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"ୱାର୍କକୁ ସୁଇଚ କରନ୍ତୁ"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"ବ୍ୟକ୍ତିଗତକୁ ସୁଇଚ କରନ୍ତୁ"</string> + <string name="picker_profile_admin_title" msgid="4172022376418293777">"ଆପଣଙ୍କ ଆଡମିନଙ୍କ ଦ୍ୱାରା ବ୍ଲକ କରାଯାଇଛି"</string> + <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"କୌଣସି ବ୍ୟକ୍ତିଗତ ଆପରୁ ୱାର୍କ ଡାଟାକୁ ଆକ୍ସେସ କରିବା ପାଇଁ ଅନୁମତି ଦିଆଯାଇନାହିଁ"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"କୌଣସି ୱାର୍କ ଆପରୁ ବ୍ୟକ୍ତିଗତ ଡାଟାକୁ ଆକ୍ସେସ୍ କରିବା ପାଇଁ ଅନୁମତି ଦିଆଯାଇନାହିଁ"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"ୱାର୍କ ଆପଗୁଡ଼ିକୁ ବିରତ କରାଯାଇଛି"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"ୱାର୍କ ଫଟୋଗୁଡ଼ିକୁ ଖୋଲିବାକୁ, ଆପଣଙ୍କ ୱାର୍କ ଆପଗୁଡ଼ିକୁ ଚାଲୁ କରି ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ"</string> @@ -72,7 +76,8 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g>ଟି ଆଇଟମ}other{<xliff:g id="COUNT_1">^1</xliff:g>ଟି ଆଇଟମ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>)ଟି ଯୋଗ କରନ୍ତୁ"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"ଅନୁମତି ଦିଅନ୍ତୁ (<xliff:g id="COUNT">^1</xliff:g>)"</string> - <string name="picker_category_camera" msgid="4857367052026843664">"କ୍ୟାମେରା"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"କାହାରିକୁ ଅନୁମତି ଦିଅନ୍ତୁ ନାହିଁ"</string> + <string name="picker_category_camera" msgid="4857367052026843664">"କେମେରା"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ଡାଉନଲୋଡଗୁଡ଼ିକ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ପସନ୍ଦଗୁଡ଼ିକ"</string> <string name="picker_category_screenshots" msgid="7216102327587644284">"ସ୍କ୍ରିନସଟଗୁଡ଼ିକ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ଭିଡିଓ ପ୍ଲେ କରିବାରେ ସମସ୍ୟା"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ଆପଣଙ୍କ ଇଣ୍ଟରନେଟ କନେକ୍ସନ ଯାଞ୍ଚ କରି ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ବର୍ତ୍ତମାନ <xliff:g id="PKG_NAME">%1$s</xliff:g>ରୁ କ୍ଲାଉଡ ମିଡିଆ ଉପଲବ୍ଧ ଅଛି"</string> <string name="not_selected" msgid="2244008151669896758">"ଚୟନ କରାଯାଇନାହିଁ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"ଆପଣଙ୍କ ଚୟନିତ ମିଡିଆକୁ ପ୍ରସ୍ତୁତ କରାଯାଉଛି"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>ରୁ <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>ଟି ପ୍ରସ୍ତୁତ ଅଛି"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ବାତିଲ କରନ୍ତୁ"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ବ୍ୟାକଅପ ନିଆଯାଇଥିବା ଫଟୋଗୁଡ଼ିକୁ ବର୍ତ୍ତମାନ ଅନ୍ତର୍ଭୁକ୍ତ କରାଯାଇଛି"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ଆପଣ <xliff:g id="APP_NAME">%1$s</xliff:g> ଆକାଉଣ୍ଟ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>ରୁ ଫଟୋଗୁଡ଼ିକୁ ଚୟନ କରିପାରିବେ"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ଆକାଉଣ୍ଟକୁ ଅପଡେଟ କରାଯାଇଛି"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ଆପ ବାଛନ୍ତୁ"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"ଆକାଉଣ୍ଟ ବାଛନ୍ତୁ"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ଆକାଉଣ୍ଟ ବଦଳାନ୍ତୁ"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ଆପଣଙ୍କର ସମସ୍ତ ଫଟୋ ପ୍ରାପ୍ତ କରାଯାଉଛି"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{ଏହି ଅଡିଓ ଫାଇଲକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_0">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?}other{<xliff:g id="COUNT">^2</xliff:g>ଟି ଅଡିଓ ଫାଇଲକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_1">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ଅଡିଓ ଫାଇଲ ପରିବର୍ତ୍ତନ କରାଯାଉଛି…}other{<xliff:g id="COUNT">^1</xliff:g>ଟି ଅଡିଓ ଫାଇଲ ପରିବର୍ତ୍ତନ କରାଯାଉଛି…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{ଏହି ଭିଡିଓକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_0">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?}other{<xliff:g id="COUNT">^2</xliff:g>ଟି ଭିଡିଓକୁ ପରିବର୍ତ୍ତନ କରିବା ପାଇଁ <xliff:g id="APP_NAME_1">^1</xliff:g>କୁ ଅନୁମତି ଦେବେ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"ସୁରକ୍ଷିତ ସୁରକ୍ଷା"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ନେଟିଭ ଟ୍ରାନ୍ସକୋଡ ଆଲର୍ଟ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ନେଟିଭ ଟ୍ରାନ୍ସକୋଡ ପ୍ରୋଗ୍ରେସ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ପରେ ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ। ସମସ୍ୟାର ସମାଧାନ ହେବା ପରେ ଆପଣଙ୍କ ଫଟୋଗୁଡ଼ିକ ଉପଲବ୍ଧ ହେବ।"</string> + <string name="dialog_error_title" msgid="636349284077820636">"କିଛି ଫଟୋ ଲୋଡ କରାଯାଇପାରିବ ନାହିଁ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ବୁଝିଗଲି"</string> </resources> diff --git a/res/values-pa/strings.xml b/res/values-pa/strings.xml index f6922bbac..beb933e43 100644 --- a/res/values-pa/strings.xml +++ b/res/values-pa/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"ਮੀਡੀਆ"</string> <string name="storage_description" msgid="4081716890357580107">"ਸਥਾਨਕ ਸਟੋਰੇਜ"</string> - <string name="app_label" msgid="9035307001052716210">"ਮੀਡੀਆ ਸਟੋਰੇਜ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"ਮੀਡੀਆ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"ਮੀਡੀਆ ਚੋਣਕਾਰ"</string> <string name="artist_label" msgid="8105600993099120273">"ਕਲਾਕਾਰ"</string> <string name="unknown" msgid="2059049215682829375">"ਅਗਿਆਤ"</string> <string name="root_images" msgid="5861633549189045666">"ਚਿੱਤਰ"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"ਇੱਥੋਂ ਕਲਾਊਡ ਮੀਡੀਆ ਤੱਕ ਪਹੁੰਚ ਕਰੋ"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ਕੋਈ ਨਹੀਂ"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ਇਸ ਸਮੇਂ ਕਲਾਊਡ ਮੀਡੀਆ ਐਪ ਨੂੰ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ।"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"ਮੀਡੀਆ ਚੋਣਕਾਰ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"ਮੀਡੀਆ ਚੋਣਕਾਰ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"ਮੀਡੀਆ ਸਿੰਕ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ…"</string> <string name="add" msgid="2894574044585549298">"ਸ਼ਾਮਲ ਕਰੋ"</string> <string name="deselect" msgid="4297825044827769490">"ਅਣ-ਚੁਣਿਆ ਕਰੋ"</string> <string name="deselected" msgid="8488133193326208475">"ਅਣ-ਚੁਣਿਆ"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ਕੋਈ ਐਲਬਮ ਨਹੀਂ"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ਚੁਣੀਆਂ ਗਈਆਂ ਆਈਟਮਾਂ ਦੇਖੋ"</string> <string name="picker_photos" msgid="7415035516411087392">"ਫ਼ੋਟੋਆਂ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ਐਲਬਮਾਂ"</string> <string name="picker_preview" msgid="6257414886055861039">"ਪੂਰਵ-ਝਲਕ"</string> <string name="picker_work_profile" msgid="2083221066869141576">"ਕਾਰਜ ਪ੍ਰੋਫਾਈਲ \'ਤੇ ਸਵਿੱਚ ਕਰੋ"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ਆਈਟਮ}one{<xliff:g id="COUNT_1">^1</xliff:g> ਆਈਟਮ}other{<xliff:g id="COUNT_1">^1</xliff:g> ਆਈਟਮਾਂ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) ਸ਼ਾਮਲ ਕਰੋ"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"ਆਗਿਆ ਦਿਓ (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ਕੋਈ ਵੀ ਆਗਿਆ ਨਾ ਦਿਓ"</string> <string name="picker_category_camera" msgid="4857367052026843664">"ਕੈਮਰਾ"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ਡਾਊਨਲੋਡ"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ਮਨਪਸੰਦ"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ਵੀਡੀਓ ਚਲਾਉਣ ਵਿੱਚ ਸਮੱਸਿਆ ਆ ਰਹੀ ਹੈ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ਆਪਣੇ ਇੰਟਰਨੈੱਟ ਕਨੈਕਸ਼ਨ ਦੀ ਜਾਂਚ ਕਰ ਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਕਰੋ"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ਕਲਾਊਡ ਮੀਡੀਆ ਹੁਣ <xliff:g id="PKG_NAME">%1$s</xliff:g> ਤੋਂ ਉਪਲਬਧ ਹੈ"</string> <string name="not_selected" msgid="2244008151669896758">"ਚੁਣਿਆ ਨਹੀਂ ਗਿਆ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"ਤੁਹਾਡਾ ਚੁਣਿਆ ਗਿਆ ਮੀਡੀਆ ਤਿਆਰ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ਵਿੱਚੋਂ <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> ਤਿਆਰ"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ਰੱਦ ਕਰੋ"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"ਬੈਕਅੱਪ ਕੀਤੀਆਂ ਫ਼ੋਟੋਆਂ ਨੂੰ ਹੁਣ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ਤੁਸੀਂ ਖਾਤੇ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ਦੀ <xliff:g id="APP_NAME">%1$s</xliff:g> ਵਿੱਚੋਂ ਫ਼ੋਟੋਆਂ ਨੂੰ ਚੁਣੋ"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ਤੁਸੀਂ <xliff:g id="APP_NAME">%1$s</xliff:g> ਵਿੱਚੋਂ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ਖਾਤੇ ਤੋਂ ਫ਼ੋਟੋਆਂ ਚੁਣ ਸਕਦੇ ਹੋ"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ਖਾਤੇ ਨੂੰ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"<xliff:g id="USER_ACCOUNT">%1$s</xliff:g> ਦੀਆਂ ਫ਼ੋਟੋਆਂ ਨੂੰ ਹੁਣ ਇੱਥੇ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"ਕਲਾਊਡ ਮੀਡੀਆ ਐਪ ਨੂੰ ਚੁਣੋ"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ਐਪ ਚੁਣੋ"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"ਖਾਤਾ ਚੁਣੋ"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ਖਾਤਾ ਬਦਲੋ"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ਤੁਹਾਡੀਆਂ ਸਾਰੀਆਂ ਫ਼ੋਟੋਆਂ ਪ੍ਰਾਪਤ ਕੀਤੀਆਂ ਜਾ ਰਹੀਆਂ ਹਨ"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{ਕੀ <xliff:g id="APP_NAME_0">^1</xliff:g> ਨੂੰ ਇਸ ਆਡੀਓ ਫ਼ਾਈਲ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}one{ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}other{ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲਾਂ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ਆਡੀਓ ਫ਼ਾਈਲ ਸੋਧੀ ਜਾ ਰਹੀ ਹੈ…}one{<xliff:g id="COUNT">^1</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲ ਸੋਧੀ ਜਾ ਰਹੀ ਹੈ…}other{<xliff:g id="COUNT">^1</xliff:g> ਆਡੀਓ ਫ਼ਾਈਲਾਂ ਸੋਧੀਆਂ ਜਾ ਰਹੀਆਂ ਹਨ…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{ਕੀ <xliff:g id="APP_NAME_0">^1</xliff:g> ਨੂੰ ਇਸ ਵੀਡੀਓ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}one{ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਵੀਡੀਓ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}other{ਕੀ <xliff:g id="APP_NAME_1">^1</xliff:g> ਨੂੰ <xliff:g id="COUNT">^2</xliff:g> ਵੀਡੀਓ ਨੂੰ ਸੋਧਣ ਦੇਣਾ ਹੈ?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"ਸੁਰੱਖਿਆ ਬਚਾਅ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"ਨੇਟਿਵ ਟ੍ਰਾਂਸਕੋਡ ਸੁਚੇਤਨਾਵਾਂ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"ਨੇਟਿਵ ਟ੍ਰਾਂਸਕੋਡ ਪ੍ਰਗਤੀ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"ਬਾਅਦ ਵਿੱਚ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ। ਸਮੱਸਿਆ ਹੱਲ ਹੋਣ ਤੋਂ ਬਾਅਦ ਤੁਹਾਡੀਆਂ ਫ਼ੋਟੋਆਂ ਉਪਲਬਧ ਹੋ ਜਾਣਗੀਆਂ।"</string> + <string name="dialog_error_title" msgid="636349284077820636">"ਕੁਝ ਫ਼ੋਟੋਆਂ ਨੂੰ ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ਸਮਝ ਲਿਆ"</string> </resources> diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml index 432521b7d..6ac9ce345 100644 --- a/res/values-pl/strings.xml +++ b/res/values-pl/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia"</string> <string name="storage_description" msgid="4081716890357580107">"Pamięć lokalna"</string> - <string name="app_label" msgid="9035307001052716210">"Przechowywanie multimediów"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimedia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Wybór mediów"</string> <string name="artist_label" msgid="8105600993099120273">"Wykonawca"</string> <string name="unknown" msgid="2059049215682829375">"Nieznany"</string> <string name="root_images" msgid="5861633549189045666">"Obrazy"</string> @@ -46,10 +45,13 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Otwieraj multimedia w chmurze za pomocą:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Brak"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Nie udało się zmienić aplikacji do multimediów w chmurze."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Wybór mediów"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Wybór mediów"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synchronizuję multimedia…"</string> <string name="add" msgid="2894574044585549298">"Dodaj"</string> - <string name="deselect" msgid="4297825044827769490">"Odznacz"</string> + <string name="deselect" msgid="4297825044827769490">"Usuń wybór"</string> <string name="deselected" msgid="8488133193326208475">"Usunięto wybór"</string> - <string name="select" msgid="2704765470563027689">"Zaznacz"</string> + <string name="select" msgid="2704765470563027689">"Wybierz"</string> <string name="selected" msgid="9151797369975828124">"Wybrano"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Wybierz maksymalnie <xliff:g id="COUNT_0">^1</xliff:g> element}few{Wybierz maksymalnie <xliff:g id="COUNT_1">^1</xliff:g> elementy}many{Wybierz maksymalnie <xliff:g id="COUNT_1">^1</xliff:g> elementów}other{Wybierz maksymalnie <xliff:g id="COUNT_1">^1</xliff:g> elementu}}"</string> <string name="recent" msgid="6694613584743207874">"Ostatnie"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Brak albumów"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Wyświetl wybrane"</string> <string name="picker_photos" msgid="7415035516411087392">"Zdjęcia"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumy"</string> <string name="picker_preview" msgid="6257414886055861039">"Podgląd"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Włącz profil służbowy"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}few{<xliff:g id="COUNT_1">^1</xliff:g> elementy}many{<xliff:g id="COUNT_1">^1</xliff:g> elementów}other{<xliff:g id="COUNT_1">^1</xliff:g> elementu}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Dodaj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Zezwól (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nie zezwalaj na żadne"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Aparat"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Pobrane"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Ulubione"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Wystąpiły problemy przy odtwarzaniu filmu"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Sprawdź połączenie z internetem i spróbuj ponownie"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Ponów"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Multimedia w chmurze są teraz dostępne z poziomu aplikacji <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nie wybrano"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Przygotowywanie wybranych multimediów"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Gotowe <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> z <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Anuluj"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Teraz znajdziesz tu kopie zapasowe zdjęć"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Możesz wybrać zdjęcia z aplikacji <xliff:g id="APP_NAME">%1$s</xliff:g>, z konta <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Konto aplikacji <xliff:g id="APP_NAME">%1$s</xliff:g> zostało zaktualizowane"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Wybierz aplikację"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Wybierz konto"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Zmień konto"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Pobieram wszystkie Twoje zdjęcia"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Zezwolić aplikacji <xliff:g id="APP_NAME_0">^1</xliff:g> na zmodyfikowanie tego pliku audio?}few{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> plików audio?}many{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> plików audio?}other{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> pliku audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Modyfikuję plik audio…}few{Modyfikuję <xliff:g id="COUNT">^1</xliff:g> pliki audio…}many{Modyfikuję <xliff:g id="COUNT">^1</xliff:g> plików audio…}other{Modyfikuję <xliff:g id="COUNT">^1</xliff:g> pliku audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Zezwolić aplikacji <xliff:g id="APP_NAME_0">^1</xliff:g> na zmodyfikowanie tego filmu?}few{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> filmów?}many{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> filmów?}other{Zezwolić aplikacji <xliff:g id="APP_NAME_1">^1</xliff:g> na zmodyfikowanie <xliff:g id="COUNT">^2</xliff:g> filmu?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Sprzęt zabezpieczający"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alerty dotyczące transkodowania natywnego"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Postępy transkodowania natywnego"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Spróbuj ponownie później. Zdjęcia będą dostępne po rozwiązaniu problemu."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nie można wczytać niektórych zdjęć"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-pt-rBR/strings.xml b/res/values-pt-rBR/strings.xml index cd37d766c..326fde29e 100644 --- a/res/values-pt-rBR/strings.xml +++ b/res/values-pt-rBR/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Mídia"</string> <string name="storage_description" msgid="4081716890357580107">"Armazenamento local"</string> - <string name="app_label" msgid="9035307001052716210">"Armazenamento de mídia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Mídia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Seletor de mídia"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconhecido"</string> <string name="root_images" msgid="5861633549189045666">"Imagens"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Acessar a mídia em nuvem de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nenhum"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Não foi possível mudar o app de mídia em nuvem."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Seletor de mídia"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Seletor de mídia"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizando mídia…"</string> <string name="add" msgid="2894574044585549298">"Adicionar"</string> <string name="deselect" msgid="4297825044827769490">"Desmarcar"</string> <string name="deselected" msgid="8488133193326208475">"Desmarcada"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Sem álbuns"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Mostrar selecionados"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbuns"</string> <string name="picker_preview" msgid="6257414886055861039">"Visualização"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Mudar para \"Trabalho\""</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Mudar para \"Pessoal\""</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Mudar para Trabalho"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Mudar para Pessoal"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bloqueado pelo administrador"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Não é permitido o acesso a dados de trabalho em um app pessoal"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Não é permitido o acesso a dados pessoais em um app de trabalho"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}one{<xliff:g id="COUNT_1">^1</xliff:g> item}many{<xliff:g id="COUNT_1">^1</xliff:g> itens}other{<xliff:g id="COUNT_1">^1</xliff:g> itens}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Adicionar (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Não autorizar"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Câmera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Ocorreu um problema ao iniciar o vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Confira sua conexão de Internet e tente de novo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Tentar novamente"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Mídia em nuvem agora disponível no app <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"não selecionado"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparando a mídia selecionada"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> itens prontos"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Fotos salvas em backup agora estão incluídas"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"As fotos salvas em backup agora estão incluídas"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Selecione fotos da conta <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> do app <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"A conta do app <xliff:g id="APP_NAME">%1$s</xliff:g> foi atualizada"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"As fotos da conta <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> agora estão incluídas aqui"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Proteção"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertas da transcodificação nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progresso da transcodificação nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Tente de novo mais tarde. Suas fotos vão ficar disponíveis assim que o problema for resolvido."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Não é possível carregar algumas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entendi"</string> </resources> diff --git a/res/values-pt-rPT/strings.xml b/res/values-pt-rPT/strings.xml index 83b7aad47..dddf82cc6 100644 --- a/res/values-pt-rPT/strings.xml +++ b/res/values-pt-rPT/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimédia"</string> <string name="storage_description" msgid="4081716890357580107">"Armazenamento local"</string> - <string name="app_label" msgid="9035307001052716210">"Armazenamento de multimédia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Multimédia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Seletor de meios"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconhecido"</string> <string name="root_images" msgid="5861633549189045666">"Imagens"</string> @@ -33,7 +32,7 @@ <string name="permission_more_thumb" msgid="1938863829470531577">"{count,plural, =1{+<xliff:g id="COUNT_0">^1</xliff:g>}many{+<xliff:g id="COUNT_1">^1</xliff:g>}other{+<xliff:g id="COUNT_1">^1</xliff:g>}}"</string> <string name="permission_more_text" msgid="2471785045095597753">"{count,plural, =1{E <xliff:g id="COUNT_0">^1</xliff:g> item adicional}many{E <xliff:g id="COUNT_1">^1</xliff:g> itens adicionais}other{E <xliff:g id="COUNT_1">^1</xliff:g> itens adicionais}}"</string> <string name="cache_clearing_dialog_title" msgid="8907893815183913664">"Limpe ficheiros de apps temporários"</string> - <string name="cache_clearing_dialog_text" msgid="7057784635111940957">"A app <xliff:g id="APP_SEEKING_PERMISSION">%s</xliff:g> pretende limpar alguns ficheiros temporários. Isto pode resultar num aumento da utilização da bateria ou dos dados móveis."</string> + <string name="cache_clearing_dialog_text" msgid="7057784635111940957">"A app <xliff:g id="APP_SEEKING_PERMISSION">%s</xliff:g> quer limpar alguns ficheiros temporários. Isto pode resultar num aumento da utilização da bateria ou dos dados móveis."</string> <string name="cache_clearing_in_progress_title" msgid="6902220064511664209">"A limpar ficheiros temporários da app…"</string> <string name="clear" msgid="5524638938415865915">"Limpar"</string> <string name="allow" msgid="8885707816848569619">"Permitir"</string> @@ -46,8 +45,11 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Aceda a multimédia na nuvem a partir de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nenhuma"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Impossível alterar a app de multimédia na nuvem."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Seletor de meios"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Seletor de meios"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"A sincronizar conteúdo multimédia…"</string> <string name="add" msgid="2894574044585549298">"Adicionar"</string> - <string name="deselect" msgid="4297825044827769490">"Desselecionar"</string> + <string name="deselect" msgid="4297825044827769490">"Desmarcar"</string> <string name="deselected" msgid="8488133193326208475">"Desmarcado"</string> <string name="select" msgid="2704765470563027689">"Selecionar"</string> <string name="selected" msgid="9151797369975828124">"Selecionado"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nenhum álbum"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ver selecionado(s)"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbuns"</string> <string name="picker_preview" msgid="6257414886055861039">"Pré-visualizar"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Mudar para trabalho"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}many{<xliff:g id="COUNT_1">^1</xliff:g> itens}other{<xliff:g id="COUNT_1">^1</xliff:g> itens}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Adicionar (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Não permitir nenhuma"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Câmara"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Transferências"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problema ao reproduzir o vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Verifique a ligação à Internet e tente novamente"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Tentar novamente"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Multimédia da nuvem já disponível da app <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"não selecionado"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"A preparar conteúdo multimédia selecionado"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> item(ns) pronto(s)"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"As fotos com cópia de segurança já estão incluídas"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Pode selecionar fotos da app <xliff:g id="APP_NAME">%1$s</xliff:g> da conta <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Conta da app <xliff:g id="APP_NAME">%1$s</xliff:g> atualizada"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Escolher app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Escolher conta"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Alterar conta"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"A obter todas as suas fotos"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Permitir que a app <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este ficheiro de áudio?}many{Permitir que a app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> ficheiros de áudio?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{A modificar o ficheiro de áudio…}many{A modificar <xliff:g id="COUNT">^1</xliff:g> ficheiro(s) de áudio…}other{A modificar <xliff:g id="COUNT">^1</xliff:g> ficheiro(s) de áudio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Permitir que a app <xliff:g id="APP_NAME_0">^1</xliff:g> modifique este vídeo?}many{Permitir que a app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> vídeos?}other{Permitir que a app <xliff:g id="APP_NAME_1">^1</xliff:g> modifique <xliff:g id="COUNT">^2</xliff:g> vídeos?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Proteção de segurança"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertas de transcodificação nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progresso de transcodificação nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Tente mais tarde. As suas fotos vão estar disponíveis quando o problema estiver resolvido."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Não é possível carregar algumas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-pt/strings.xml b/res/values-pt/strings.xml index cd37d766c..326fde29e 100644 --- a/res/values-pt/strings.xml +++ b/res/values-pt/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Mídia"</string> <string name="storage_description" msgid="4081716890357580107">"Armazenamento local"</string> - <string name="app_label" msgid="9035307001052716210">"Armazenamento de mídia"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Mídia"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Seletor de mídia"</string> <string name="artist_label" msgid="8105600993099120273">"Artista"</string> <string name="unknown" msgid="2059049215682829375">"Desconhecido"</string> <string name="root_images" msgid="5861633549189045666">"Imagens"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Acessar a mídia em nuvem de"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Nenhum"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Não foi possível mudar o app de mídia em nuvem."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Seletor de mídia"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Seletor de mídia"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sincronizando mídia…"</string> <string name="add" msgid="2894574044585549298">"Adicionar"</string> <string name="deselect" msgid="4297825044827769490">"Desmarcar"</string> <string name="deselected" msgid="8488133193326208475">"Desmarcada"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Sem álbuns"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Mostrar selecionados"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotos"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Álbuns"</string> <string name="picker_preview" msgid="6257414886055861039">"Visualização"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Mudar para \"Trabalho\""</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Mudar para \"Pessoal\""</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Mudar para Trabalho"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Mudar para Pessoal"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bloqueado pelo administrador"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Não é permitido o acesso a dados de trabalho em um app pessoal"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Não é permitido o acesso a dados pessoais em um app de trabalho"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}one{<xliff:g id="COUNT_1">^1</xliff:g> item}many{<xliff:g id="COUNT_1">^1</xliff:g> itens}other{<xliff:g id="COUNT_1">^1</xliff:g> itens}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Adicionar (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permitir (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Não autorizar"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Câmera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Downloads"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoritos"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Ocorreu um problema ao iniciar o vídeo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Confira sua conexão de Internet e tente de novo"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Tentar novamente"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Mídia em nuvem agora disponível no app <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"não selecionado"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Preparando a mídia selecionada"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> de <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> itens prontos"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Fotos salvas em backup agora estão incluídas"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Cancelar"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"As fotos salvas em backup agora estão incluídas"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Selecione fotos da conta <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> do app <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"A conta do app <xliff:g id="APP_NAME">%1$s</xliff:g> foi atualizada"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"As fotos da conta <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> agora estão incluídas aqui"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Proteção"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alertas da transcodificação nativa"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progresso da transcodificação nativa"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Tente de novo mais tarde. Suas fotos vão ficar disponíveis assim que o problema for resolvido."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Não é possível carregar algumas fotos"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Entendi"</string> </resources> diff --git a/res/values-ro/strings.xml b/res/values-ro/strings.xml index a8137a981..d5306a8b7 100644 --- a/res/values-ro/strings.xml +++ b/res/values-ro/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Conținut media"</string> <string name="storage_description" msgid="4081716890357580107">"Stocare locală"</string> - <string name="app_label" msgid="9035307001052716210">"Stocarea conținutului media"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Selector de suport"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Necunoscut"</string> <string name="root_images" msgid="5861633549189045666">"Imagini"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Accesează conținutul media în cloud din"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Niciuna"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Nu s-a putut schimba aplicația media pentru cloud"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Selector de suport"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Selector de suport"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Se sincronizează conținutul media…"</string> <string name="add" msgid="2894574044585549298">"Adaugă"</string> <string name="deselect" msgid="4297825044827769490">"Debifează"</string> <string name="deselected" msgid="8488133193326208475">"Deselectat"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Niciun album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Vezi elementele selectate"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotografii"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albume"</string> <string name="picker_preview" msgid="6257414886055861039">"Previzualizare"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Comută la serviciu"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}few{<xliff:g id="COUNT_1">^1</xliff:g> elemente}other{<xliff:g id="COUNT_1">^1</xliff:g> de elemente}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Adaugă (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Permite (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nu permite nimic"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Cameră"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Descărcări"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Preferate"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Probleme la redarea videoclipului"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Verifică-ți conexiunea la internet și încearcă din nou"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Încearcă din nou"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Conținutul media în cloud este acum disponibil din <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"neselectat"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Se pregătește conținutul media selectat"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Finalizate: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> din <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Anulează"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Fotografiile cu backup sunt incluse acum"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Poți selecta fotografii din contul <xliff:g id="APP_NAME">%1$s</xliff:g> <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Contul <xliff:g id="APP_NAME">%1$s</xliff:g> a fost actualizat"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Alege aplicația"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Alege un cont"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Schimbă contul"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Se încarcă toate fotografiile"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Permiți ca <xliff:g id="APP_NAME_0">^1</xliff:g> să modifice acest fișier audio?}few{Permiți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> fișiere audio?}other{Permiți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> de fișiere audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Se modifică fișierul audio…}few{Se modifică <xliff:g id="COUNT">^1</xliff:g> fișiere audio…}other{Se modifică <xliff:g id="COUNT">^1</xliff:g> de fișiere audio…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Permiți ca <xliff:g id="APP_NAME_0">^1</xliff:g> să modifice acest videoclip?}few{Permiți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> videoclipuri?}other{Permiți ca <xliff:g id="APP_NAME_1">^1</xliff:g> să modifice <xliff:g id="COUNT">^2</xliff:g> de videoclipuri?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Protecția în caz de accidente"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Alerte privind transcodarea în codul nativ"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progresul transcodării în codul nativ"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Încearcă din nou mai târziu. Fotografiile tale vor fi disponibile după ce se rezolvă problema."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Unele fotografii nu pot fi încărcate"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml index 87a0859c2..18d77ea0c 100644 --- a/res/values-ru/strings.xml +++ b/res/values-ru/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Мультимедиа"</string> <string name="storage_description" msgid="4081716890357580107">"Локальное хранилище"</string> - <string name="app_label" msgid="9035307001052716210">"Хранилище мультимедиа"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Мультимедиа"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Инструмент выбора медиа"</string> <string name="artist_label" msgid="8105600993099120273">"Исполнитель"</string> <string name="unknown" msgid="2059049215682829375">"Неизвестно"</string> <string name="root_images" msgid="5861633549189045666">"Изображения"</string> @@ -43,9 +42,12 @@ <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Приложение для мультимедиа в облаке"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Приложение для мультимедиа в облаке"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Выбирайте свои фото и видео из облака в приложениях или на сайтах."</string> - <string name="picker_settings_selection_message" msgid="245453573086488596">"Получите доступ к мультимедиа в облаке"</string> + <string name="picker_settings_selection_message" msgid="245453573086488596">"Какое приложение использовать для доступа к медиафайлам в облаке?"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Нет"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Не удалось изменить приложение для мультимедиа."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Инструмент выбора медиа"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Инструмент выбора медиа"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Синхронизация медиаконтента…"</string> <string name="add" msgid="2894574044585549298">"Добавить"</string> <string name="deselect" msgid="4297825044827769490">"Отменить выбор"</string> <string name="deselected" msgid="8488133193326208475">"Выбор отменен"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Альбомов нет."</string> <string name="picker_view_selected" msgid="2266031384396143883">"Посмотреть выбранное"</string> <string name="picker_photos" msgid="7415035516411087392">"Фотографии"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Альбомы"</string> <string name="picker_preview" msgid="6257414886055861039">"Предварительный просмотр"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Перейти в рабочий профиль"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> объект}one{<xliff:g id="COUNT_1">^1</xliff:g> объект}few{<xliff:g id="COUNT_1">^1</xliff:g> объекта}many{<xliff:g id="COUNT_1">^1</xliff:g> объектов}other{<xliff:g id="COUNT_1">^1</xliff:g> объекта}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Добавить (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Открыть доступ (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Запретить доступ всем"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Скачанные"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Избранное"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Не удалось воспроизвести видео"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Проверьте подключение к интернету и повторите попытку."</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Повторить"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Медиаконтент из облака теперь доступен в приложении \"<xliff:g id="PKG_NAME">%1$s</xliff:g>\"."</string> <string name="not_selected" msgid="2244008151669896758">"не выбрано"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Подготовка выбранных медиафайлов"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Предзагрузка: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> из <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Резервные копии фотографий добавлены"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Отмена"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Теперь можно выбирать фотографии в облаке"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Вы можете выбрать фотографии из аккаунта <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> приложения \"<xliff:g id="APP_NAME">%1$s</xliff:g>\"."</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>: аккаунт обновлен"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Фотографии из аккаунта <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> теперь хранятся здесь."</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Выбрать приложение"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Выбрать аккаунт"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Сменить аккаунт"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Ваши фотографии загружаются"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Разрешить приложению \"<xliff:g id="APP_NAME_0">^1</xliff:g>\" изменить этот аудиофайл?}one{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайл?}few{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайла?}many{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайлов?}other{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> аудиофайла?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Изменение аудиофайла…}one{Изменение <xliff:g id="COUNT">^1</xliff:g> аудиофайла…}few{Изменение <xliff:g id="COUNT">^1</xliff:g> аудиофайлов…}many{Изменение <xliff:g id="COUNT">^1</xliff:g> аудиофайлов…}other{Изменение <xliff:g id="COUNT">^1</xliff:g> аудиофайла…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Разрешить приложению \"<xliff:g id="APP_NAME_0">^1</xliff:g>\" изменить это видео?}one{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> видео?}few{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> видео?}many{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> видео?}other{Разрешить приложению \"<xliff:g id="APP_NAME_1">^1</xliff:g>\" изменить <xliff:g id="COUNT">^2</xliff:g> видео?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Защита безопасности"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Уведомления нативного перекодирования"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Прогресс нативного перекодирования"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Повторите попытку позже. Ваши фотографии станут доступны после устранения проблемы."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Не удается загрузить некоторые фотографии"</string> + <string name="dialog_button_text" msgid="351366485240852280">"ОК"</string> </resources> diff --git a/res/values-si/strings.xml b/res/values-si/strings.xml index 53566bbbe..e7c1b3cd9 100644 --- a/res/values-si/strings.xml +++ b/res/values-si/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"මාධ්ය"</string> <string name="storage_description" msgid="4081716890357580107">"පෙදෙසි ආචයනය"</string> - <string name="app_label" msgid="9035307001052716210">"මාධ්ය ගබඩාව"</string> - <string name="picker_app_label" msgid="4254039089502164761">"මාධ්ය"</string> + <string name="picker_app_label" msgid="1195424381053599122">"මාධ්ය තෝරනය"</string> <string name="artist_label" msgid="8105600993099120273">"කලාකරු"</string> <string name="unknown" msgid="2059049215682829375">"නොදනී"</string> <string name="root_images" msgid="5861633549189045666">"රූප"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"මෙයින් ක්ලවුඩ් මාධ්ය වෙත ප්රවේශ වන්න"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"කිසිවක් නැත"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"මෙම අවස්ථාවේ ක්ලවුඩ් මාධ්ය යෙදුම වෙනස් කළ නොහැක."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"මාධ්ය තෝරනය"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"මාධ්ය තෝරනය"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"මාධ්ය සමමුහුර්ත කරමින්…"</string> <string name="add" msgid="2894574044585549298">"එක් කරන්න"</string> <string name="deselect" msgid="4297825044827769490">"නොතෝරන්න"</string> <string name="deselected" msgid="8488133193326208475">"නොතෝරන ලද"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ඇල්බම නැත"</string> <string name="picker_view_selected" msgid="2266031384396143883">"තෝරා ගත් දේවල් බලන්න"</string> <string name="picker_photos" msgid="7415035516411087392">"ඡායාරූප"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ඇල්බම"</string> <string name="picker_preview" msgid="6257414886055861039">"පෙරදසුන"</string> <string name="picker_work_profile" msgid="2083221066869141576">"කාර්යාලය වෙත මාරු වන්න"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{අයිතම <xliff:g id="COUNT_0">^1</xliff:g>}one{අයිතම <xliff:g id="COUNT_1">^1</xliff:g>}other{අයිතම <xliff:g id="COUNT_1">^1</xliff:g>}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"එක් කරන්න (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"ඉඩ දෙන්න (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"කිසිවකට ඉඩ නොදෙන්න"</string> <string name="picker_category_camera" msgid="4857367052026843664">"කැමරාව"</string> <string name="picker_category_downloads" msgid="793866660287361900">"බාගැනීම්"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ප්රියතමයන්"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"වීඩියෝව වාදනය කිරීමේ ගැටලුවකි"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ඔබේ අන්තර්ජාල සබැඳුම පරීක්ෂා කර නැවත උත්සාහ කරන්න"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"යළි උත්සාහ කරන්න"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ක්ලවුඩ් මාධ්ය දැන් <xliff:g id="PKG_NAME">%1$s</xliff:g> වෙතින් ලබා ගත හැකිය"</string> <string name="not_selected" msgid="2244008151669896758">"තෝරා නොමැත"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"ඔබ තෝරන ලද මාධ්ය සූදානම් කරමින්"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>කින් <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>ක් සූදානම්"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"අවලංගු කරන්න"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"උපස්ථ කළ ඡායාරූප දැන් ඇතුළත් කර ඇත"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"ඔබට <xliff:g id="APP_NAME">%1$s</xliff:g> ගිණුමෙන් <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ඡායාරූප තෝරා ගත හැක"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ගිණුම යාවත්කාලීන විය"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"යෙදුම තෝරා ගන්න"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"ගිණුම තෝරා ගන්න"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"ගිණුම වෙනස් කරන්න"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"ඔබේ සියලු ඡායාරූප ලබා ගැනීම"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> හට මෙම ශ්රව්ය ගොනුව වෙනස් කිරීමට ඉඩ දෙන්නද?}one{<xliff:g id="APP_NAME_1">^1</xliff:g> හට ශ්රව්ය ගොනු <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> හට ශ්රව්ය ගොනු <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ශ්රව්ය ගොනුව වෙනස් කරමින්…}one{ශ්රව්ය ගොනු <xliff:g id="COUNT">^1</xliff:g>ක් වෙනස් කරමින්…}other{ශ්රව්ය ගොනු <xliff:g id="COUNT">^1</xliff:g>ක් වෙනස් කරමින්…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> හට මෙම වීඩියෝව වෙනස් කිරීමට ඉඩ දෙන්නද?}one{<xliff:g id="APP_NAME_1">^1</xliff:g> හට වීඩියෝ <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> හට වීඩියෝ <xliff:g id="COUNT">^2</xliff:g>ක් වෙනස් කිරීමට ඉඩ දෙන්නද?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"සුරක්ෂිතතා ආරක්ෂණය"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"සහජ ට්රාන්ස්කෝඩ් ඇඟවීම්"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"සහජ ට්රාන්ස්කෝඩ් ප්රගතිය"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"පසුව නැවත උත්සාහ කරන්න. ගැටලුව විසඳූ පසු ඔබේ ඡායාරූප ලබා ගත හැකි වනු ඇත."</string> + <string name="dialog_error_title" msgid="636349284077820636">"සමහර ඡායාරූප පූරණය කළ නොහැක"</string> + <string name="dialog_button_text" msgid="351366485240852280">"තේරුණා"</string> </resources> diff --git a/res/values-sk/strings.xml b/res/values-sk/strings.xml index 0c28291c4..b77206a1d 100644 --- a/res/values-sk/strings.xml +++ b/res/values-sk/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Médiá"</string> <string name="storage_description" msgid="4081716890357580107">"Miestne úložisko"</string> - <string name="app_label" msgid="9035307001052716210">"Úložisko médií"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Médiá"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Nástroj na výber médií"</string> <string name="artist_label" msgid="8105600993099120273">"Interpret"</string> <string name="unknown" msgid="2059049215682829375">"Neznáme"</string> <string name="root_images" msgid="5861633549189045666">"Obrázky"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Získavať prístup k médiám v cloude v aplikácii"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Žiadne"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Momentálne sa nepodarilo zmeniť cloudový prehrávač"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Nástroj na výber médií"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Nástroj na výber médií"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synchronizujú sa médiá…"</string> <string name="add" msgid="2894574044585549298">"Pridať"</string> <string name="deselect" msgid="4297825044827769490">"Zrušiť výber"</string> <string name="deselected" msgid="8488133193326208475">"Výber bol zrušený"</string> @@ -58,20 +60,23 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Žiadne albumy"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Zobraziť vybrané"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotky"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumy"</string> <string name="picker_preview" msgid="6257414886055861039">"Ukážka"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Prepnúť na pracovný"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Prepnúť na osobný"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Prepnúť na pracovný profil"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Prepnúť na osobný profil"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Blokované vaším správcom"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Prístup k pracovným údajom z osobnej aplikácie nie je povolený"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Prístup k osobným údajom z pracovnej aplikácie nie je povolený"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Pracovné aplikácie sú pozastavené"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"Ak chcete otvoriť pracovné fotky, zapnite pracovné aplikácie a skúste to znova"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"Táto aplikácia môže mať prístup iba k fotkám, ktoré vyberiete"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"Táto aplikácia má prístup iba k fotkám, ktoré vyberiete"</string> <string name="picker_header_permissions" msgid="675872774407768495">"Vyberte fotky a videá, ku ktorým má mať táto aplikácia prístup"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> položka}few{<xliff:g id="COUNT_1">^1</xliff:g> položky}many{<xliff:g id="COUNT_1">^1</xliff:g> items}other{<xliff:g id="COUNT_1">^1</xliff:g> položiek}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Pridať (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Povoliť (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Nepovoliť žiadne"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Stiahnuté"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Obľúbené"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Ťažkosti s prehrávaním videa"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Skontrolujte internetové pripojenie a skúste to znova"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Skúsiť znova"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Cloudové médiá sú teraz k dispozícii z aplikácie <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nevybrané"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Pripravujú sa vybrané médiá"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Pripravené: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> z <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Zrušiť"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Zálohované fotky sú teraz zahrnuté"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Môžete vybrať fotky z účtu <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> aplikácie <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Môžete vyberať fotky z účtu <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> aplikácie <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Účet <xliff:g id="APP_NAME">%1$s</xliff:g> bol aktualizovaný"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Odteraz sú tu zahrnuté fotky z účtu <xliff:g id="USER_ACCOUNT">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"Vyberte cloudovú aplikáciu s médiami"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Bezpečnosť"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Upozornenia natívneho prekódovania"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Postup natívneho prekódovania"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Skúste to neskôr. Po vyriešení problému budú vaše fotky k dispozícii."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Niektoré fotky sa nedajú načítať"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Dobre"</string> </resources> diff --git a/res/values-sl/strings.xml b/res/values-sl/strings.xml index 1e9d72ed6..119ad244b 100644 --- a/res/values-sl/strings.xml +++ b/res/values-sl/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Predstavnost"</string> <string name="storage_description" msgid="4081716890357580107">"Lokalna shramba"</string> - <string name="app_label" msgid="9035307001052716210">"Shramba za predstavnost"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Predstavnost"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Orodje za izbiranje predstavnosti"</string> <string name="artist_label" msgid="8105600993099120273">"Izvajalec"</string> <string name="unknown" msgid="2059049215682829375">"Neznano"</string> <string name="root_images" msgid="5861633549189045666">"Slike"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Dostop do predstavnosti v oblaku v storitvi"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Brez"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Zamenjava aplikacije za predstavnost v oblaku trenutno ni mogoča."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Orodje za izbiranje predstavnosti"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Orodje za izbiranje predstavnosti"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sinhroniziranje predstavnosti …"</string> <string name="add" msgid="2894574044585549298">"Dodaj"</string> <string name="deselect" msgid="4297825044827769490">"Počisti izbiro"</string> <string name="deselected" msgid="8488133193326208475">"Izbor je preklican"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Ni albumov."</string> <string name="picker_view_selected" msgid="2266031384396143883">"Prikaži izbrano"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotografije"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumi"</string> <string name="picker_preview" msgid="6257414886055861039">"Predogled"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Preklop na delovni profil"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> element}one{<xliff:g id="COUNT_1">^1</xliff:g> element}two{<xliff:g id="COUNT_1">^1</xliff:g> elementa}few{<xliff:g id="COUNT_1">^1</xliff:g> elementi}other{<xliff:g id="COUNT_1">^1</xliff:g> elementov}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Dodaj (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Dovoli (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Dovoli brez izbire"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Fotoaparat"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Prenosi"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Priljubljeno"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Težave pri predvajanju videoposnetka"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Preverite internetno povezavo in poskusite znova."</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Poskusi znova"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Predstavnost v oblaku je zdaj na voljo v aplikaciji <xliff:g id="PKG_NAME">%1$s</xliff:g>."</string> <string name="not_selected" msgid="2244008151669896758">"ni izbrano"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Pripravljanje izbranih predstavnostnih vsebin"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Pripravljenih: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> od <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Prekliči"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Varnostno kopirane fotografije so zdaj vključene"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Izberete lahko fotografije iz računa <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> za aplikacijo <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Račun za aplikacijo <xliff:g id="APP_NAME">%1$s</xliff:g> je posodobljen"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Varnostna zaščita"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Opozorila o izvornem prekodiranju"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Napredek izvornega prekodiranja"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Poskusite znova pozneje. Fotografije bodo na voljo, ko bo težava odpravljena."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Nekaterih fotografij ni mogoče naložiti"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Razumem"</string> </resources> diff --git a/res/values-sq/strings.xml b/res/values-sq/strings.xml index 95870a4d8..a8e5c1892 100644 --- a/res/values-sq/strings.xml +++ b/res/values-sq/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Hapësira ruajtëse lokale"</string> - <string name="app_label" msgid="9035307001052716210">"Hapësira ruajtëse e medias"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Zgjedhësi i medias"</string> <string name="artist_label" msgid="8105600993099120273">"Artisti"</string> <string name="unknown" msgid="2059049215682829375">"I panjohur"</string> <string name="root_images" msgid="5861633549189045666">"Fotografitë"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"Aplikacioni i medias në renë kompjuterike"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Aplikacion i medias në renë kompjuterike"</string> <string name="picker_settings_title" msgid="5647700706470673258">"Aplikacioni i medias në renë kompjuterike"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Qasu te media jote në renë kompjuterike kur një aplikacion ose sajt uebi të kërkon të zgjedhësh fotografitë ose videot"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Qasu te media jote në renë kompjuterike kur një aplikacion ose uebsajt të kërkon të zgjedhësh fotografitë ose videot"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Qasu te media në renë kompjuterike nga"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Asnjë"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Aplikacioni i medias në renë kompjuterike nuk mund të ndryshohej në këtë moment."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Zgjedhësi i medias"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Zgjedhësi i medias"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Media po sinkronizohet…"</string> <string name="add" msgid="2894574044585549298">"Shto"</string> <string name="deselect" msgid="4297825044827769490">"Hiq përzgjedhjen"</string> <string name="deselected" msgid="8488133193326208475">"Zgjedhja është hequr"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Nuk ka albume"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Shiko të zgjedhurat"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotografitë"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albumet"</string> <string name="picker_preview" msgid="6257414886055861039">"Pamja paraprake"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Ndryshoje te puna"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Ndryshoje te personale"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Kalo te profili i punës"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Kalo te profili personal"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bllokuar nga administratori yt"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Qasja e të dhënave të punës nga një aplikacion personal nuk lejohet"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Qasja e të dhënave personale nga një aplikacion pune nuk lejohet"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> artikull}other{<xliff:g id="COUNT_1">^1</xliff:g> artikuj}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Shto (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Lejo (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Mos lejo asnjë"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Shkarkimet"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Të preferuarat"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Problem me luajtjen e videos"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Kontrollo lidhjen e internetit dhe provo përsëri"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Riprovo"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Media në renë kompjuterike tani ofrohet nga <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"nuk është zgjedhur"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Media e zgjedhur po përgatitet"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> nga <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> gati"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Anulo"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Fotografitë e rezervuara tani janë të përfshira"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Mund të zgjedhësh fotografi nga llogaria e<xliff:g id="USER_ACCOUNT">%2$s</xliff:g> në <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Llogaria e <xliff:g id="APP_NAME">%1$s</xliff:g> u përditësua"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Zgjidh aplikacionin"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Zgjidh llogarinë"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Ndrysho llogarinë"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Po merren të gjitha fotografitë e tua"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Të lejohet <xliff:g id="APP_NAME_0">^1</xliff:g> që ta modifikojë këtë skedar audio?}other{Të lejohet <xliff:g id="APP_NAME_1">^1</xliff:g> që të modifikojë <xliff:g id="COUNT">^2</xliff:g> skedarë audio?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Skedari audio po modifikohet…}other{<xliff:g id="COUNT">^1</xliff:g> skedarë audio po modifikohen…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Të lejohet <xliff:g id="APP_NAME_0">^1</xliff:g> që ta modifikojë këtë video?}other{Të lejohet <xliff:g id="APP_NAME_1">^1</xliff:g> që të modifikojë <xliff:g id="COUNT">^2</xliff:g> video?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Mbrojtja e sigurisë"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Sinjalizimet e transkodimit origjinal"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Progresi i transkodimit origjinal"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Provo sërish më vonë. Fotografitë e tua do të ofrohen pasi të zgjidhet problemi."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Disa fotografi nuk mund të ngarkohen"</string> + <string name="dialog_button_text" msgid="351366485240852280">"E kuptova"</string> </resources> diff --git a/res/values-sr/strings.xml b/res/values-sr/strings.xml index 4c3f2bf5d..bda0c5f60 100644 --- a/res/values-sr/strings.xml +++ b/res/values-sr/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Медији"</string> <string name="storage_description" msgid="4081716890357580107">"Локални меморијски простор"</string> - <string name="app_label" msgid="9035307001052716210">"Меморијски простор за медије"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Медији"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Бирач медија"</string> <string name="artist_label" msgid="8105600993099120273">"Извођач"</string> <string name="unknown" msgid="2059049215682829375">"Непознато"</string> <string name="root_images" msgid="5861633549189045666">"Слике"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Приступајте медијима у клауду из"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Ништа"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Промена апликације за медије у клауду није успела."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Бирач медија"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Бирач медија"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Медији се синхронизују…"</string> <string name="add" msgid="2894574044585549298">"Додај"</string> <string name="deselect" msgid="4297825044827769490">"Опозови избор"</string> <string name="deselected" msgid="8488133193326208475">"Опозван је избор"</string> @@ -53,11 +55,13 @@ <string name="selected" msgid="9151797369975828124">"Изабрано"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{Изаберите највише <xliff:g id="COUNT_0">^1</xliff:g> ставку}one{Изаберите највише <xliff:g id="COUNT_1">^1</xliff:g> ставку}few{Изаберите највише <xliff:g id="COUNT_1">^1</xliff:g> ставке}other{Изаберите највише <xliff:g id="COUNT_1">^1</xliff:g> ставки}}"</string> <string name="recent" msgid="6694613584743207874">"Недавно"</string> - <string name="picker_photos_empty_message" msgid="5980619500554575558">"Нема слика нити видео снимака"</string> - <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Нема подржаних слика нити видео снимака"</string> + <string name="picker_photos_empty_message" msgid="5980619500554575558">"Нема слика нити видеа"</string> + <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Нема подржаних слика нити видеа"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"Нема албума"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Прикажи изабранo"</string> <string name="picker_photos" msgid="7415035516411087392">"Слике"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Албуми"</string> <string name="picker_preview" msgid="6257414886055861039">"Преглед"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Пређи на пословни профил"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ставка}one{<xliff:g id="COUNT_1">^1</xliff:g> ставка}few{<xliff:g id="COUNT_1">^1</xliff:g> ставке}other{<xliff:g id="COUNT_1">^1</xliff:g> ставки}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Додај (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Дозволи (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Не дозволи ниједну"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Преузето"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Омиљено"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Дошло је до грешке при пуштању видеа"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Проверите интернет везу и пробајте поново"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Пробај поново"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"<xliff:g id="PKG_NAME">%1$s</xliff:g> сада нуди медијски садржај у клауду"</string> <string name="not_selected" msgid="2244008151669896758">"није изабрано"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Припремају се одабрани медијски фајлови"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Спремно:<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> од <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Откажи"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Сада су уврштене резервне копије слика"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Можете да изаберете слике са налога <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> за <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Налог за <xliff:g id="APP_NAME">%1$s</xliff:g> је ажуриран"</string> @@ -107,36 +113,35 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Одабери апликацију"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Одабери налог"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Промени налог"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Преузимају се све слике"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> измени овај аудио фајл?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> аудио фајл?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> аудио фајла?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> аудио фајлова?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Мења се аудио фајл…}one{Мења се <xliff:g id="COUNT">^1</xliff:g> аудио фајл…}few{Мењају се <xliff:g id="COUNT">^1</xliff:g> аудио фајла…}other{Мења се <xliff:g id="COUNT">^1</xliff:g> аудио фајлова…}}"</string> - <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> измени овај видео?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видео?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видео снимка?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видео снимака?}}"</string> - <string name="permission_progress_write_video" msgid="7014908418349819148">"{count,plural, =1{Мења се видео…}one{Мења се <xliff:g id="COUNT">^1</xliff:g> видео…}few{Мењају се <xliff:g id="COUNT">^1</xliff:g> видео снимка…}other{Мења се <xliff:g id="COUNT">^1</xliff:g> видео снимака…}}"</string> + <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> измени овај видео?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видео?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видео снимка?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> видеа?}}"</string> + <string name="permission_progress_write_video" msgid="7014908418349819148">"{count,plural, =1{Мења се видео…}one{Мења се <xliff:g id="COUNT">^1</xliff:g> видео…}few{Мењају се <xliff:g id="COUNT">^1</xliff:g> видео снимка…}other{Мења се <xliff:g id="COUNT">^1</xliff:g> видеа…}}"</string> <string name="permission_write_image" msgid="3518991791620523786">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> измени ову слику?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> слику?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> слике?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> слика?}}"</string> <string name="permission_progress_write_image" msgid="3623580315590025262">"{count,plural, =1{Мења се слика…}one{Мења се <xliff:g id="COUNT">^1</xliff:g> слика…}few{Мењају се <xliff:g id="COUNT">^1</xliff:g> слике…}other{Мења се <xliff:g id="COUNT">^1</xliff:g> слика…}}"</string> <string name="permission_write_generic" msgid="7431128739233656991">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> измени ову ставку?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> ставку?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> ставке?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> измени <xliff:g id="COUNT">^2</xliff:g> ставки?}}"</string> <string name="permission_progress_write_generic" msgid="2806560971318391443">"{count,plural, =1{Мења се ставка…}one{Мења се <xliff:g id="COUNT">^1</xliff:g> ставка…}few{Мењају се <xliff:g id="COUNT">^1</xliff:g> ставке…}other{Мења се <xliff:g id="COUNT">^1</xliff:g> ставки…}}"</string> <string name="permission_trash_audio" msgid="6554672354767742206">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај аудио фајл у отпад?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајл у отпад?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајла у отпад?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајлова у отпад?}}"</string> <string name="permission_progress_trash_audio" msgid="3116279868733641329">"{count,plural, =1{Аудио фајл се премешта у отпад…}one{<xliff:g id="COUNT">^1</xliff:g> аудио фајл се премешта у отпад…}few{<xliff:g id="COUNT">^1</xliff:g> аудио фајла се премештају у отпад…}other{<xliff:g id="COUNT">^1</xliff:g> аудио фајлова се премешта у отпад…}}"</string> - <string name="permission_trash_video" msgid="7555850843259959642">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај видео у отпад?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео у отпад?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимка у отпад?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимака у отпад?}}"</string> - <string name="permission_progress_trash_video" msgid="4637821778329459681">"{count,plural, =1{Видео се премешта у отпад…}one{<xliff:g id="COUNT">^1</xliff:g> видео се премешта у отпад…}few{<xliff:g id="COUNT">^1</xliff:g> видео снимка се премештају у отпад…}other{<xliff:g id="COUNT">^1</xliff:g> видео снимака се премешта у отпад…}}"</string> + <string name="permission_trash_video" msgid="7555850843259959642">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај видео у отпад?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео у отпад?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимка у отпад?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видеа у отпад?}}"</string> + <string name="permission_progress_trash_video" msgid="4637821778329459681">"{count,plural, =1{Видео се премешта у отпад…}one{<xliff:g id="COUNT">^1</xliff:g> видео се премешта у отпад…}few{<xliff:g id="COUNT">^1</xliff:g> видео снимка се премештају у отпад…}other{<xliff:g id="COUNT">^1</xliff:g> видеа се премешта у отпад…}}"</string> <string name="permission_trash_image" msgid="3333128084684156675">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести ову слику у отпад?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слику у отпад?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слике у отпад?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слика у отпад?}}"</string> <string name="permission_progress_trash_image" msgid="3063857679090024764">"{count,plural, =1{Слика се премешта у отпад…}one{<xliff:g id="COUNT">^1</xliff:g> слика се премешта у отпад…}few{<xliff:g id="COUNT">^1</xliff:g> слике се премештају у отпад…}other{<xliff:g id="COUNT">^1</xliff:g> слика се премешта у отпад…}}"</string> <string name="permission_trash_generic" msgid="5545420534785075362">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести ову ставку у отпад?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставку у отпад?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставке у отпад?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставки у отпад?}}"</string> <string name="permission_progress_trash_generic" msgid="7815124979717814057">"{count,plural, =1{Ставка се премешта у отпад…}one{<xliff:g id="COUNT">^1</xliff:g> ставка се премешта у отпад…}few{<xliff:g id="COUNT">^1</xliff:g> ставке се премештају у отпад…}other{<xliff:g id="COUNT">^1</xliff:g> ставки се премешта у отпад…}}"</string> <string name="permission_untrash_audio" msgid="8404597563284002472">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај аудио фајл из отпада?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајл из отпада?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајла из отпада?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> аудио фајлова из отпада?}}"</string> <string name="permission_progress_untrash_audio" msgid="2775372344946464508">"{count,plural, =1{Аудио фајл се премешта из отпада…}one{<xliff:g id="COUNT">^1</xliff:g> аудио фајл се премешта из отпада…}few{<xliff:g id="COUNT">^1</xliff:g> аудио фајла се премештају из отпада…}other{<xliff:g id="COUNT">^1</xliff:g> аудио фајлова се премешта из отпада…}}"</string> - <string name="permission_untrash_video" msgid="3178914827607608162">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај видео из отпада?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео из отпада?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимка из отпада?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимака из отпада?}}"</string> - <string name="permission_progress_untrash_video" msgid="5500929409733841567">"{count,plural, =1{Видео се премешта из отпада…}one{<xliff:g id="COUNT">^1</xliff:g> видео се премешта из отпада…}few{<xliff:g id="COUNT">^1</xliff:g> видео снимка се премештају из отпада…}other{<xliff:g id="COUNT">^1</xliff:g> видео снимака се премешта из отпада…}}"</string> + <string name="permission_untrash_video" msgid="3178914827607608162">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести овај видео из отпада?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео из отпада?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видео снимка из отпада?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> видеа из отпада?}}"</string> + <string name="permission_progress_untrash_video" msgid="5500929409733841567">"{count,plural, =1{Видео се премешта из отпада…}one{<xliff:g id="COUNT">^1</xliff:g> видео се премешта из отпада…}few{<xliff:g id="COUNT">^1</xliff:g> видео снимка се премештају из отпада…}other{<xliff:g id="COUNT">^1</xliff:g> видеа се премешта из отпада…}}"</string> <string name="permission_untrash_image" msgid="3397523279351032265">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести ову слику из отпада?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слику из отпада?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слике из отпада?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> слика из отпада?}}"</string> <string name="permission_progress_untrash_image" msgid="5295061520504846264">"{count,plural, =1{Слика се премешта из отпада…}one{<xliff:g id="COUNT">^1</xliff:g> слика се премешта из отпада…}few{<xliff:g id="COUNT">^1</xliff:g> слике се премештају из отпада…}other{<xliff:g id="COUNT">^1</xliff:g> слика се премешта из отпада…}}"</string> <string name="permission_untrash_generic" msgid="2118366929431671046">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> премести ову ставку из отпада?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставку из отпада?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставке из отпада?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> премести <xliff:g id="COUNT">^2</xliff:g> ставки из отпада?}}"</string> <string name="permission_progress_untrash_generic" msgid="1489511601966842579">"{count,plural, =1{Ставка се премешта из отпада…}one{<xliff:g id="COUNT">^1</xliff:g> ставка се премешта из отпада…}few{<xliff:g id="COUNT">^1</xliff:g> ставке се премештају из отпада…}other{<xliff:g id="COUNT">^1</xliff:g> ставки се премешта из отпада…}}"</string> <string name="permission_delete_audio" msgid="3326674742892796627">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> избрише овај аудио фајл?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> аудио фајл?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> аудио фајла?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> аудио фајлова?}}"</string> <string name="permission_progress_delete_audio" msgid="1734871539021696401">"{count,plural, =1{Брише се аудио фајл…}one{Брише се <xliff:g id="COUNT">^1</xliff:g> аудио фајл…}few{Бришу се <xliff:g id="COUNT">^1</xliff:g> аудио фајла…}other{Брише се <xliff:g id="COUNT">^1</xliff:g> аудио фајлова…}}"</string> - <string name="permission_delete_video" msgid="604024971828349279">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> избрише овај видео?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видео?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видео снимка?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видео снимака?}}"</string> - <string name="permission_progress_delete_video" msgid="1846702435073793157">"{count,plural, =1{Брише се видео…}one{Брише се <xliff:g id="COUNT">^1</xliff:g> видео…}few{Бришу се <xliff:g id="COUNT">^1</xliff:g> видео снимка…}other{Брише се <xliff:g id="COUNT">^1</xliff:g> видео снимака…}}"</string> + <string name="permission_delete_video" msgid="604024971828349279">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> избрише овај видео?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видео?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видео снимка?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> видеа?}}"</string> + <string name="permission_progress_delete_video" msgid="1846702435073793157">"{count,plural, =1{Брише се видео…}one{Брише се <xliff:g id="COUNT">^1</xliff:g> видео…}few{Бришу се <xliff:g id="COUNT">^1</xliff:g> видео снимка…}other{Брише се <xliff:g id="COUNT">^1</xliff:g> видеа…}}"</string> <string name="permission_delete_image" msgid="3109056012794330510">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> избрише ову слику?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> слику?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> слике?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> слика?}}"</string> <string name="permission_progress_delete_image" msgid="8580517204901148906">"{count,plural, =1{Брише се слика…}one{Брише се <xliff:g id="COUNT">^1</xliff:g> слика…}few{Бришу се <xliff:g id="COUNT">^1</xliff:g> слике…}other{Брише се <xliff:g id="COUNT">^1</xliff:g> слика…}}"</string> <string name="permission_delete_generic" msgid="7891939881065520271">"{count,plural, =1{Желите ли да дозволите да <xliff:g id="APP_NAME_0">^1</xliff:g> избрише ову ставку?}one{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> ставку?}few{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> ставке?}other{Желите ли да дозволите да <xliff:g id="APP_NAME_1">^1</xliff:g> избрише <xliff:g id="COUNT">^2</xliff:g> ставки?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Сигурносна заштита"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Обавештења о основном транскодирању"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Ток основног транскодирања"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Пробајте поново касније. Слике ће бити доступне када се проблем реши."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Учитавање неких слика није успело"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Важи"</string> </resources> diff --git a/res/values-sv/strings.xml b/res/values-sv/strings.xml index 1031806dc..4ebe03475 100644 --- a/res/values-sv/strings.xml +++ b/res/values-sv/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Lokal lagring"</string> - <string name="app_label" msgid="9035307001052716210">"Medialagring"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Medieväljaren"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Okänd"</string> <string name="root_images" msgid="5861633549189045666">"Bilder"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Få tillgång till media i molnet från"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Inga"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Det går inte att byta molnmedieapp just nu."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Medieväljaren"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Medieväljaren"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Synkroniserar media …"</string> <string name="add" msgid="2894574044585549298">"Lägg till"</string> <string name="deselect" msgid="4297825044827769490">"Avmarkera"</string> <string name="deselected" msgid="8488133193326208475">"Avmarkerad"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Inga album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Visa valda"</string> <string name="picker_photos" msgid="7415035516411087392">"Foton"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Förhandsgranska"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Byt till jobbprofilen"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> objekt}other{<xliff:g id="COUNT_1">^1</xliff:g> objekt}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Lägg till (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Tillåt (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Tillåt inga"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Nedladdningar"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoriter"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Det gick inte att spela upp videon"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Kontrollera internetanslutningen och försök igen"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Försök igen"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Molnmedia är nu tillgänglig från <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"inte valt"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Din valda media förbereds"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> av <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> är redo"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Avbryt"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Säkerhetskopierade foton tas nu med"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Du kan välja foton från <xliff:g id="APP_NAME">%1$s</xliff:g>-kontot <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>-kontot har uppdaterats"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Välj app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Välj konto"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Byt konto"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Läser in alla dina foton"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vill du tillåta att <xliff:g id="APP_NAME_0">^1</xliff:g> ändrar den här ljudfilen?}other{Vill du tillåta att <xliff:g id="APP_NAME_1">^1</xliff:g> ändrar <xliff:g id="COUNT">^2</xliff:g> ljudfiler?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Ljudfilen ändras …}other{<xliff:g id="COUNT">^1</xliff:g> ljudfiler ändras …}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vill du tillåta att <xliff:g id="APP_NAME_0">^1</xliff:g> ändrar den här videon?}other{Vill du tillåta att <xliff:g id="APP_NAME_1">^1</xliff:g> ändrar <xliff:g id="COUNT">^2</xliff:g> videor?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Säkerhet"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Omkodningsvarningar för Native"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Omkodningsförlopp för Native"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Försök igen senare. Dina foton blir tillgängliga när problemet har lösts."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Det gick inte att läsa in vissa foton"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-sw/strings.xml b/res/values-sw/strings.xml index a3f016fca..67f8103ec 100644 --- a/res/values-sw/strings.xml +++ b/res/values-sw/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Maudhui"</string> <string name="storage_description" msgid="4081716890357580107">"Hifadhi ya ndani"</string> - <string name="app_label" msgid="9035307001052716210">"Hifadhi ya Maudhui"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Maudhui"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Kiteua maudhui"</string> <string name="artist_label" msgid="8105600993099120273">"Msanii"</string> <string name="unknown" msgid="2059049215682829375">"Isiyojulikana"</string> <string name="root_images" msgid="5861633549189045666">"Picha"</string> @@ -39,13 +38,16 @@ <string name="allow" msgid="8885707816848569619">"Ruhusu"</string> <string name="deny" msgid="6040983710442068936">"Kataa"</string> <string name="picker_browse" msgid="5554477454636075934">"Vinjari…"</string> - <string name="picker_settings" msgid="6443463167344790260">"Programu ya maudhui ya Wingu"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Programu ya maudhui ya Wingu"</string> - <string name="picker_settings_title" msgid="5647700706470673258">"Programu ya maudhui ya kwenye wingu"</string> + <string name="picker_settings" msgid="6443463167344790260">"Programu ya maudhui ya wingu"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Programu ya maudhui ya wingu"</string> + <string name="picker_settings_title" msgid="5647700706470673258">"Programu ya maudhui ya wingu"</string> <string name="picker_settings_description" msgid="2916686824777214585">"Fikia maudhui kwenye wingu lako programu au tovuti inapokuomba uchague picha au video"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"Fikia maudhui ya kwenye wingu katika"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Hamna"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Imeshindwa kubadilisha programu ya maudhui ya wingu kwa wakati huu."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Kiteua maudhui"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Kiteua maudhui"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Inasawazisha maudhui…"</string> <string name="add" msgid="2894574044585549298">"Weka"</string> <string name="deselect" msgid="4297825044827769490">"Acha kuchagua"</string> <string name="deselected" msgid="8488133193326208475">"Umeacha kuchagua"</string> @@ -58,10 +60,12 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Hakuna albamu"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Angalia ulizochagua"</string> <string name="picker_photos" msgid="7415035516411087392">"Picha"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albamu"</string> <string name="picker_preview" msgid="6257414886055861039">"Onyesho la kukagua"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Badili uweke wasifu wa kazini"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Badili uweke wasifu wa binafsi"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Badili utumie wasifu wa kazini"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Badili utumie wasifu wa binafsi"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Umezuiwa na msimamizi wako"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Huruhusiwi kufikia data ya kazini kwenye programu ya binafsi"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Huruhusiwi kufikia data binafsi kwenye programu ya kazini"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{Kipengee <xliff:g id="COUNT_0">^1</xliff:g>}other{Vipengee <xliff:g id="COUNT_1">^1</xliff:g>}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Weka (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Ruhusu (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Usiruhusu yoyote"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Vipakuliwa"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Vipendwa"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Tatizo limetokea wakati wa kucheza video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Angalia muunganisho wako wa intaneti na ujaribu tena"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Jaribu tena"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Maudhui ya kwenye wingu sasa yanapatikana katika <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"haijachaguliwa"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Inaandaa maudhui yako uliyochagua"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> kati ya <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ziko tayari"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Ghairi"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Picha zilizohifadhiwa nakala zimejumuishwa sasa"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Unaweza kuchagua picha zilizotoka kwenye akaunti ya <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> katika programu ya <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Umesasisha akaunti ya <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Chagua programu"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Chagua akaunti"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Badilisha akaunti"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Inapakia picha zako zote"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Ungependa kuruhusu <xliff:g id="APP_NAME_0">^1</xliff:g> ibadilishe faili hii ya sauti?}other{Ungependa kuruhusu <xliff:g id="APP_NAME_1">^1</xliff:g> ibadilishe faili <xliff:g id="COUNT">^2</xliff:g> za sauti?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Inarekebisha faili ya sauti…}other{Inarekebisha faili <xliff:g id="COUNT">^1</xliff:g> za sauti…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Ungependa kuruhusu <xliff:g id="APP_NAME_0">^1</xliff:g> ibadilishe video hii?}other{Ungependa kuruhusu <xliff:g id="APP_NAME_1">^1</xliff:g> ibadilishe video <xliff:g id="COUNT">^2</xliff:g>?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Ulinzi wa Usalama"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Arifa za Ubadilishaji Asilia wa Muundo wa Faili"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Maendeleo ya Ubadilishaji Asilia wa Muundo wa Faili"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Jaribu tena baadaye. Picha zako zitapatikana mara tu tatizo litakapotatuliwa."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Imeshindwa kupakia baadhi ya Picha"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Nimeelewa"</string> </resources> diff --git a/res/values-ta/strings.xml b/res/values-ta/strings.xml index 5f29ea0c9..d53729d6f 100644 --- a/res/values-ta/strings.xml +++ b/res/values-ta/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"மீடியா"</string> <string name="storage_description" msgid="4081716890357580107">"சாதனச் சேமிப்பகம்"</string> - <string name="app_label" msgid="9035307001052716210">"மீடியா சேமிப்பிடம்"</string> - <string name="picker_app_label" msgid="4254039089502164761">"மீடியா"</string> + <string name="picker_app_label" msgid="1195424381053599122">"மீடியா தேர்வுக் கருவி"</string> <string name="artist_label" msgid="8105600993099120273">"கலைஞர்"</string> <string name="unknown" msgid="2059049215682829375">"அறியாதது"</string> <string name="root_images" msgid="5861633549189045666">"Images"</string> @@ -46,18 +45,23 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"கிளவுட் மீடியாவை இதிலிருந்து அணுகும்"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"எதுவுமில்லை"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"இப்போது கிளவுடு மீடியா ஆப்ஸை மாற்ற முடியாது"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"மீடியா தேர்வுக் கருவி"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"மீடியா தேர்வுக் கருவி"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"மீடியாவை ஒத்திசைக்கிறது…"</string> <string name="add" msgid="2894574044585549298">"சேர்"</string> <string name="deselect" msgid="4297825044827769490">"தேர்வுநீக்கு"</string> <string name="deselected" msgid="8488133193326208475">"தேர்வுநீக்கப்பட்டது"</string> <string name="select" msgid="2704765470563027689">"தேர்ந்தெடு"</string> <string name="selected" msgid="9151797369975828124">"தேர்ந்தெடுக்கப்பட்டது"</string> - <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> படத்தைத் தேர்ந்தெடுங்கள்}other{<xliff:g id="COUNT_1">^1</xliff:g> படங்களைத் தேர்ந்தெடுங்கள்}}"</string> + <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> படத்தைத் தேர்ந்தெடுங்கள்}other{<xliff:g id="COUNT_1">^1</xliff:g> படங்கள் வரை தேர்ந்தெடுங்கள்}}"</string> <string name="recent" msgid="6694613584743207874">"சமீபத்தியவை"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"படங்களோ வீடியோக்களோ இல்லை"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"ஆதரிக்கப்படும் படங்களோ வீடியோக்களோ இல்லை"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"ஆல்பங்கள் இல்லை"</string> <string name="picker_view_selected" msgid="2266031384396143883">"தேர்ந்தெடுத்ததைக் காட்டு"</string> <string name="picker_photos" msgid="7415035516411087392">"படங்கள்"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ஆல்பங்கள்"</string> <string name="picker_preview" msgid="6257414886055861039">"மாதிரிக்காட்சி"</string> <string name="picker_work_profile" msgid="2083221066869141576">"பணிச் சுயவிவரத்திற்கு மாறு"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ஆவணம்}other{<xliff:g id="COUNT_1">^1</xliff:g> ஆவணங்கள்}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) படங்களைச் சேர்"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"அனுமதி (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"எதையும் அனுமதிக்காதே"</string> <string name="picker_category_camera" msgid="4857367052026843664">"கேமரா"</string> <string name="picker_category_downloads" msgid="793866660287361900">"பதிவிறக்கங்கள்"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"பிடித்தவை"</string> @@ -92,11 +97,12 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"வீடியோவைப் பிளே செய்வதில் சிக்கல்"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"உங்கள் இணைய இணைப்பைச் சரிபார்த்துவிட்டு மீண்டும் முயலவும்"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"மீண்டும் முயல்க"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"கிளவுட் மீடியா <xliff:g id="PKG_NAME">%1$s</xliff:g> ஆப்ஸில் தற்போது கிடைக்கிறது"</string> <string name="not_selected" msgid="2244008151669896758">"தேர்ந்தெடுக்கப்படவில்லை"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"நீங்கள் தேர்ந்தெடுத்த மீடியா தயாராகிறது"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> / <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> தயாராக உள்ளது"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ரத்துசெய்"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"காப்புப் பிரதி எடுக்கப்பட்ட படங்கள் இப்போது சேர்க்கப்பட்டுள்ளன"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> ஆப்ஸிலிருந்தும் <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> கணக்கிலிருந்தும் படங்களைத் தேர்ந்தெடுக்கலாம்"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> ஆப்ஸில் <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> கணக்கிலிருந்தும் படங்களைத் தேர்ந்தெடுக்கலாம்"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> கணக்கு புதுப்பிக்கப்பட்டது"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"<xliff:g id="USER_ACCOUNT">%1$s</xliff:g> கணக்கிலிருந்த படங்களும் இப்போது சேர்க்கப்பட்டுள்ளன"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"கிளவுட் மீடியா ஆப்ஸைத் தேர்வுசெய்தல்"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ஆப்ஸைத் தேர்வுசெய்க"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"கணக்கைத் தேர்வுசெய்க"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"கணக்கை மாற்று"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"உங்கள் படங்கள் அனைத்தையும் பெறுகிறது"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{இந்த ஆடியோ ஃபைலில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_0">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?}other{<xliff:g id="COUNT">^2</xliff:g> ஆடியோ ஃபைல்களில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_1">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{ஆடியோ ஃபைலை மாற்றியமைக்கிறது…}other{<xliff:g id="COUNT">^1</xliff:g> ஆடியோ ஃபைல்களை மாற்றியமைக்கிறது…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{இந்த வீடியோவில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_0">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?}other{<xliff:g id="COUNT">^2</xliff:g> வீடியோக்களில் மாற்றங்களைச் செய்ய <xliff:g id="APP_NAME_1">^1</xliff:g> ஆப்ஸை அனுமதிக்கவா?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"பாதுகாப்பு வளையம்"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"நேட்டிவ் குறிமாற்ற விழிப்பூட்டல்கள்"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"நேட்டிவ் குறிமாற்றச் செயல்நிலை"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"பிறகு மீண்டும் முயலவும். சிக்கல் சரியானதும் உங்கள் படங்கள் கிடைக்கும்."</string> + <string name="dialog_error_title" msgid="636349284077820636">"சில படங்களை ஏற்ற முடியவில்லை"</string> + <string name="dialog_button_text" msgid="351366485240852280">"சரி"</string> </resources> diff --git a/res/values-te/strings.xml b/res/values-te/strings.xml index 831eb4a36..3e8469b37 100644 --- a/res/values-te/strings.xml +++ b/res/values-te/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"మీడియా"</string> <string name="storage_description" msgid="4081716890357580107">"స్థానిక స్టోరేజ్"</string> - <string name="app_label" msgid="9035307001052716210">"మీడియా స్టోరేజ్"</string> - <string name="picker_app_label" msgid="4254039089502164761">"మీడియా"</string> + <string name="picker_app_label" msgid="1195424381053599122">"మీడియా సెలెక్టర్"</string> <string name="artist_label" msgid="8105600993099120273">"కళాకారుడు"</string> <string name="unknown" msgid="2059049215682829375">"తెలియదు"</string> <string name="root_images" msgid="5861633549189045666">"ఇమేజ్లు"</string> @@ -46,9 +45,12 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"దాని నుండి క్లౌడ్ మీడియాను యాక్సెస్ చేయండి"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ఏవీ లేవు"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"ఈ సమయంలో క్లౌడ్ మీడియా యాప్ మార్చడం సాధ్యపడలేదు."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"మీడియా సెలెక్టర్"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"మీడియా సెలెక్టర్"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"మీడియాను సింక్ చేస్తోంది…"</string> <string name="add" msgid="2894574044585549298">"జోడించండి"</string> <string name="deselect" msgid="4297825044827769490">"ఎంపికను తొలగించండి"</string> - <string name="deselected" msgid="8488133193326208475">"ఎంపికను తొలగించండి"</string> + <string name="deselected" msgid="8488133193326208475">"ఎంపిక తొలగించబడింది"</string> <string name="select" msgid="2704765470563027689">"ఎంచుకోండి"</string> <string name="selected" msgid="9151797369975828124">"ఎంచుకోబడింది"</string> <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{గరిష్ఠంగా <xliff:g id="COUNT_0">^1</xliff:g> ఐటెమ్ను ఎంచుకోండి}other{గరిష్ఠంగా <xliff:g id="COUNT_1">^1</xliff:g> ఐటెమ్లను ఎంచుకోండి}}"</string> @@ -58,9 +60,11 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ఆల్బమ్లు ఏవీ లేవు"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ఎంచుకున్న వాటిని చూడండి"</string> <string name="picker_photos" msgid="7415035516411087392">"ఫోటోలు"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"ఆల్బమ్లు"</string> <string name="picker_preview" msgid="6257414886055861039">"ప్రివ్యూ"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"ఆఫీస్ ప్రొఫైల్కు మార్చండి"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"వర్క్ ప్రొఫైల్కు మార్చండి"</string> <string name="picker_personal_profile" msgid="639484258397758406">"వ్యక్తిగత ప్రొఫైల్కు మార్చండి"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"మీ అడ్మిన్ బ్లాక్ చేశారు"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"వ్యక్తిగత యాప్ నుండి వర్క్ డేటాను యాక్సెస్ చేయడం అనుమతించబడదు"</string> @@ -68,10 +72,11 @@ <string name="picker_profile_work_paused_title" msgid="382212880704235925">"వర్క్ యాప్లు పాజ్ చేయబడ్డాయి"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"వర్క్ ఫోటోలను తెరవడానికి, మీ వర్క్ యాప్లను ఆన్ చేసి, ఆపై మళ్లీ ట్రై చేయండి"</string> <string name="picker_privacy_message" msgid="9132700451027116817">"ఈ యాప్ మీరు ఎంచుకున్న ఫోటోలను మాత్రమే యాక్సెస్ చేయగలదు"</string> - <string name="picker_header_permissions" msgid="675872774407768495">"ఏ ఫోటోలు, వీడియోలను ఈ యాప్ యాక్సెస్ చేయవచ్చు అని మీరు అనుకుంటున్నారో వాటిని ఎంచుకోండి"</string> + <string name="picker_header_permissions" msgid="675872774407768495">"ఈ యాప్, యాక్సెస్ చేయడానికి మీరు అనుమతించే ఫోటోలను, వీడియోలను ఎంచుకోండి"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ఐటెమ్}other{<xliff:g id="COUNT_1">^1</xliff:g> ఐటెమ్లు}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"జోడించండి (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"అనుమతించండి (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"వేటినీ అనుమతించవద్దు"</string> <string name="picker_category_camera" msgid="4857367052026843664">"కెమెరా"</string> <string name="picker_category_downloads" msgid="793866660287361900">"డౌన్లోడ్లు"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"ఫేవరెట్స్"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"వీడియోను ప్లే చేయడంలో సమస్య ఏర్పడింది"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"మీ ఇంటర్నెట్ కనెక్షన్ను చెక్ చేసి, మళ్ళీ ట్రై చేయండి"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"మళ్లీ ట్రై చేయండి"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"క్లౌడ్ మీడియా ఇప్పుడు <xliff:g id="PKG_NAME">%1$s</xliff:g> నుండి అందుబాటులో ఉంది"</string> <string name="not_selected" msgid="2244008151669896758">"ఎంచుకోబడలేదు"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"మీరు ఎంచుకున్న మీడియాను సిద్ధం చేస్తోంది"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>లో <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> సిద్ధంగా ఉన్నాయి"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"రద్దు చేయండి"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"బ్యాకప్ చేసిన ఫోటోలు ఇప్పుడు చేర్చబడ్డాయి"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"మీరు <xliff:g id="APP_NAME">%1$s</xliff:g> ఖాతా <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> నుండి ఫోటోలను ఎంచుకోవచ్చు"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> ఖాతా అప్డేట్ చేయబడింది"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"భద్రత రక్షణ"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"స్థానిక ట్రాన్స్కోడ్ అలర్ట్లు"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"స్థానిక ట్రాన్స్కోడ్ ప్రోగ్రెస్"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"తర్వాత మళ్లీ ట్రై చేయండి. సమస్య పరిష్కరించబడిన తర్వాత మీ ఫోటోలు అందుబాటులో ఉంటాయి."</string> + <string name="dialog_error_title" msgid="636349284077820636">"కొన్ని ఫోటోలను లోడ్ చేయడం సాధ్యపడదు"</string> + <string name="dialog_button_text" msgid="351366485240852280">"సరే"</string> </resources> diff --git a/res/values-th/strings.xml b/res/values-th/strings.xml index 1b4e5d3b9..a2afd2af5 100644 --- a/res/values-th/strings.xml +++ b/res/values-th/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"สื่อ"</string> <string name="storage_description" msgid="4081716890357580107">"พื้นที่เก็บข้อมูลในเครื่อง"</string> - <string name="app_label" msgid="9035307001052716210">"พื้นที่เก็บข้อมูลสื่อ"</string> - <string name="picker_app_label" msgid="4254039089502164761">"สื่อ"</string> + <string name="picker_app_label" msgid="1195424381053599122">"เครื่องมือเลือกสื่อ"</string> <string name="artist_label" msgid="8105600993099120273">"ศิลปิน"</string> <string name="unknown" msgid="2059049215682829375">"ไม่ทราบ"</string> <string name="root_images" msgid="5861633549189045666">"รูปภาพ"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"เข้าถึงสื่อในระบบคลาวด์จาก"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"ไม่มี"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"เปลี่ยนแอปสื่อบนระบบคลาวด์ไม่ได้ในขณะนี้"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"เครื่องมือเลือกสื่อ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"เครื่องมือเลือกสื่อ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"กำลังซิงค์สื่อ…"</string> <string name="add" msgid="2894574044585549298">"เพิ่ม"</string> <string name="deselect" msgid="4297825044827769490">"ยกเลิกการเลือก"</string> <string name="deselected" msgid="8488133193326208475">"ยกเลิกการเลือก"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"ไม่มีอัลบั้ม"</string> <string name="picker_view_selected" msgid="2266031384396143883">"ดูรายการที่เลือก"</string> <string name="picker_photos" msgid="7415035516411087392">"รูปภาพ"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"อัลบั้ม"</string> <string name="picker_preview" msgid="6257414886055861039">"ตัวอย่าง"</string> <string name="picker_work_profile" msgid="2083221066869141576">"เปลี่ยนไปใช้โปรไฟล์งาน"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> รายการ}other{<xliff:g id="COUNT_1">^1</xliff:g> รายการ}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"เพิ่ม (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"อนุญาต (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"ไม่อนุญาต"</string> <string name="picker_category_camera" msgid="4857367052026843664">"กล้อง"</string> <string name="picker_category_downloads" msgid="793866660287361900">"การดาวน์โหลด"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"รายการโปรด"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"เกิดปัญหาขณะเล่นวิดีโอ"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตและลองอีกครั้ง"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"ลองใหม่"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"ไฟล์สื่อจาก <xliff:g id="PKG_NAME">%1$s</xliff:g> ในระบบคลาวด์พร้อมให้ใช้งานแล้ว"</string> <string name="not_selected" msgid="2244008151669896758">"ไม่ได้เลือกไว้"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"กำลังเตรียมสื่อที่คุณเลือก"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"พร้อมแล้ว <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> จาก <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"ยกเลิก"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"รวมรูปภาพที่สำรองข้อมูลไว้แล้ว"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"คุณเลือกรูปภาพได้จากบัญชี <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> ของ \"<xliff:g id="APP_NAME">%1$s</xliff:g>\""</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"อัปเดตบัญชี \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" แล้ว"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"เลือกแอป"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"เลือกบัญชี"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"เปลี่ยนบัญชี"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"กำลังโหลดรูปภาพทั้งหมด"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{อนุญาตให้ <xliff:g id="APP_NAME_0">^1</xliff:g> แก้ไขไฟล์เสียงนี้ไหม}other{อนุญาตให้ <xliff:g id="APP_NAME_1">^1</xliff:g> แก้ไขไฟล์เสียง <xliff:g id="COUNT">^2</xliff:g> ไฟล์ไหม}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{กำลังแก้ไขไฟล์เสียง…}other{กำลังแก้ไขไฟล์เสียง <xliff:g id="COUNT">^1</xliff:g> ไฟล์…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{อนุญาตให้ <xliff:g id="APP_NAME_0">^1</xliff:g> แก้ไขวิดีโอนี้ไหม}other{อนุญาตให้ <xliff:g id="APP_NAME_1">^1</xliff:g> แก้ไขวิดีโอ <xliff:g id="COUNT">^2</xliff:g> รายการไหม}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"การปกป้องเพื่อความปลอดภัย"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"โปรดลองอีกครั้งในภายหลัง รูปภาพจะพร้อมใช้งานเมื่อปัญหาได้รับการแก้ไขแล้ว"</string> + <string name="dialog_error_title" msgid="636349284077820636">"โหลดรูปภาพบางรูปไม่ได้"</string> + <string name="dialog_button_text" msgid="351366485240852280">"รับทราบ"</string> </resources> diff --git a/res/values-tl/strings.xml b/res/values-tl/strings.xml index 594b1a332..473a335c8 100644 --- a/res/values-tl/strings.xml +++ b/res/values-tl/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Media"</string> <string name="storage_description" msgid="4081716890357580107">"Lokal na storage"</string> - <string name="app_label" msgid="9035307001052716210">"Storage ng Media"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Tagapili ng media"</string> <string name="artist_label" msgid="8105600993099120273">"Artist"</string> <string name="unknown" msgid="2059049215682829375">"Hindi alam"</string> <string name="root_images" msgid="5861633549189045666">"Mga Larawan"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"I-access ang cloud media sa"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Wala"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Hindi mapalitan ang cloud media app sa ngayon."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Tagapili ng media"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Tagapili ng media"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Sini-sync ang media…"</string> <string name="add" msgid="2894574044585549298">"Magdagdag"</string> <string name="deselect" msgid="4297825044827769490">"I-deselect"</string> <string name="deselected" msgid="8488133193326208475">"Na-deselect"</string> @@ -57,7 +59,9 @@ <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Walang sinusuportahang larawan o video"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"Walang album"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Tingnan ang napili"</string> - <string name="picker_photos" msgid="7415035516411087392">"Photos"</string> + <string name="picker_photos" msgid="7415035516411087392">"Mga Larawan"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Mga Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Preview"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Lumipat sa para sa trabaho"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> item}one{<xliff:g id="COUNT_1">^1</xliff:g> item}other{<xliff:g id="COUNT_1">^1</xliff:g> na item}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Magdagdag (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Payagan (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Walang papayagan"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Camera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Mga Download"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Mga Paborito"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Nagkakaproblema sa pag-play ng video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Tingnan ang iyong koneksyon sa internet at subukan ulit"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Subukan ulit"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Available na ang cloud media sa <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"hindi pinili"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Inihahanda ang napili mong media"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Handa na ang <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> sa <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Kanselahin"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Kasama na ngayon ang mga na-back up na larawan"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Puwede kang pumili ng mga larawan mula sa <xliff:g id="APP_NAME">%1$s</xliff:g> account na <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Na-update ang <xliff:g id="APP_NAME">%1$s</xliff:g> account"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Pumili ng app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Pumili ng account"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Magpalit ng account"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Kinukuha ang lahat ng iyong larawan"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Payagan ang <xliff:g id="APP_NAME_0">^1</xliff:g> na baguhin ang audio file na ito?}one{Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na baguhin ang <xliff:g id="COUNT">^2</xliff:g> audio file?}other{Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na baguhin ang <xliff:g id="COUNT">^2</xliff:g> na audio file?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Binabago ang audio file…}one{Nagbabago ng <xliff:g id="COUNT">^1</xliff:g> audio file…}other{Nagbabago ng <xliff:g id="COUNT">^1</xliff:g> na audio file…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Payagan ang <xliff:g id="APP_NAME_0">^1</xliff:g> na baguhin ang video na ito?}one{Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na baguhin ang <xliff:g id="COUNT">^2</xliff:g> video?}other{Payagan ang <xliff:g id="APP_NAME_1">^1</xliff:g> na baguhin ang <xliff:g id="COUNT">^2</xliff:g> na video?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Proteksyon sa kaligtasan"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Native Transcode Alerts"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Native Transcode Progress"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Subukan ulit sa ibang pagkakataon. Magiging available ang iyong mga larawan kapag nalutas na ang isyu."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Hindi ma-load ang ilang Larawan"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-tr/strings.xml b/res/values-tr/strings.xml index df9e144db..037d32fc3 100644 --- a/res/values-tr/strings.xml +++ b/res/values-tr/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Medya"</string> <string name="storage_description" msgid="4081716890357580107">"Yerel depolama"</string> - <string name="app_label" msgid="9035307001052716210">"Medya Deposu"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Medya"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Medya seçme aracı"</string> <string name="artist_label" msgid="8105600993099120273">"Sanatçı"</string> <string name="unknown" msgid="2059049215682829375">"Bilinmiyor"</string> <string name="root_images" msgid="5861633549189045666">"Resimler"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Şuradaki bulut medyasına erişin"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Yok"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Bulut medya uygulaması şu anda değiştirilemiyor."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Medya seçme aracı"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Medya seçme aracı"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Medya senkronize ediliyor…"</string> <string name="add" msgid="2894574044585549298">"Ekle"</string> <string name="deselect" msgid="4297825044827769490">"Seçimi kaldır"</string> <string name="deselected" msgid="8488133193326208475">"Seçimi kaldırıldı"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Albüm yok"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Seçilenleri görüntüle"</string> <string name="picker_photos" msgid="7415035516411087392">"Fotoğraflar"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albümler"</string> <string name="picker_preview" msgid="6257414886055861039">"Önizle"</string> <string name="picker_work_profile" msgid="2083221066869141576">"İş profiline geç"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> öğe}other{<xliff:g id="COUNT_1">^1</xliff:g> öğe}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Ekle (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"İzin ver (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Hiçbirine izin verme"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"İndirilenler"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Favoriler"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Video oynatılırken sorun oluştu"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"İnternet bağlantınızı kontrol edip tekrar deneyin"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Tekrar dene"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Bulut üzerinde saklanan medya dosyaları artık <xliff:g id="PKG_NAME">%1$s</xliff:g> uygulamasından kullanılabilir"</string> <string name="not_selected" msgid="2244008151669896758">"seçili değil"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Seçtiğiniz medyalar hazırlanıyor"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> adetten <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> adedi hazır"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"İptal"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Yedeklenen fotoğraflar artık dahil ediliyor"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="APP_NAME">%1$s</xliff:g> uygulamasındaki <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> hesabından fotoğraf seçebilirsiniz"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> hesabı güncellendi"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Uygulama seç"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Hesap seç"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Hesabı değiştir"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Tüm fotoğraflarınız alınıyor"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> uygulamasının bu ses dosyasını değiştirmesine izin verilsin mi?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Ses dosyası değiştiriliyor…}other{<xliff:g id="COUNT">^1</xliff:g> ses dosyası değiştiriliyor…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> uygulamasının bu videoyu değiştirmesine izin verilsin mi?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> uygulamasının <xliff:g id="COUNT">^2</xliff:g> videoyu değiştirmesine izin verilsin mi?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Güvenlik koruması"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Yerel Kod Dönüştürme Uyarıları"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Yerel Kod Dönüştürme İlerleme Durumu"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Daha sonra tekrar deneyin. Fotoğraflarınız, sorun çözüldükten sonra kullanılabilir."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Bazı fotoğraflar yüklenemiyor"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Anladım"</string> </resources> diff --git a/res/values-uk/strings.xml b/res/values-uk/strings.xml index 9c4dacd83..b2b3947ed 100644 --- a/res/values-uk/strings.xml +++ b/res/values-uk/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Медіа-файли"</string> <string name="storage_description" msgid="4081716890357580107">"Локальна пам’ять"</string> - <string name="app_label" msgid="9035307001052716210">"Сховище медіа-файлів"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Медіа"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Інструмент вибору медіаносія"</string> <string name="artist_label" msgid="8105600993099120273">"Виконавець"</string> <string name="unknown" msgid="2059049215682829375">"Невідомо"</string> <string name="root_images" msgid="5861633549189045666">"Зображення"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Постачальник медіаконтенту з хмари"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Немає"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Не вдалося змінити хмарний мультимедійний додаток."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Інструмент вибору медіаносія"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Інструмент вибору медіаносія"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Синхронізація медіаносіїв…"</string> <string name="add" msgid="2894574044585549298">"Додати"</string> <string name="deselect" msgid="4297825044827769490">"Не вибирати"</string> <string name="deselected" msgid="8488133193326208475">"Не вибрано"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Немає альбомів"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Переглянути вибране"</string> <string name="picker_photos" msgid="7415035516411087392">"Фото"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Альбоми"</string> <string name="picker_preview" msgid="6257414886055861039">"Попередній перегляд"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Перейти в робочий профіль"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> об’єкт}one{<xliff:g id="COUNT_1">^1</xliff:g> об’єкт}few{<xliff:g id="COUNT_1">^1</xliff:g> об’єкти}many{<xliff:g id="COUNT_1">^1</xliff:g> об’єктів}other{<xliff:g id="COUNT_1">^1</xliff:g> об’єкта}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Додати (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Дозволити (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Не дозволяти жодної фотографії"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Камера"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Завантаження"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Вибране"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Проблема з відтворенням відео"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Перевірте інтернет-з’єднання й повторіть спробу"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Повторити"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Тепер медіаконтент із хмари доступний у додатку <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"не вибрано"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Підготовка вибраних медіафайлів"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"Готово: <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> з <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g>"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Скасувати"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Резервні копії фотографій додано"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Ви можете вибрати фотографії з облікового запису <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> у додатку <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Обліковий запис у додатку <xliff:g id="APP_NAME">%1$s</xliff:g> оновлено"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Вибрати додаток"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Вибрати обліковий запис"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Змінити обліковий запис"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Завантажуються всі ваші фото"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Дозволити додатку <xliff:g id="APP_NAME_0">^1</xliff:g> змінити цей аудіофайл?}one{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайл?}few{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайли?}many{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайлів?}other{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> аудіофайлу?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Змінення аудіофайлу…}one{Змінення <xliff:g id="COUNT">^1</xliff:g> аудіофайлу…}few{Змінення <xliff:g id="COUNT">^1</xliff:g> аудіофайлів…}many{Змінення <xliff:g id="COUNT">^1</xliff:g> аудіофайлів…}other{Змінення <xliff:g id="COUNT">^1</xliff:g> аудіофайлу…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Дозволити додатку <xliff:g id="APP_NAME_0">^1</xliff:g> змінити це відео?}one{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> відео?}few{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> відео?}many{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> відео?}other{Дозволити додатку <xliff:g id="APP_NAME_1">^1</xliff:g> змінити <xliff:g id="COUNT">^2</xliff:g> відео?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Захист"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Cповіщення про перекодування нативного коду"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Перебіг перекодування нативного коду"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Повторіть спробу пізніше. Ваші фотографії будуть доступні після вирішення проблеми."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Не вдається завантажити деякі фотографії"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-ur/strings.xml b/res/values-ur/strings.xml index 69bcef640..dc215afd2 100644 --- a/res/values-ur/strings.xml +++ b/res/values-ur/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"میڈیا"</string> <string name="storage_description" msgid="4081716890357580107">"مقامی اسٹوریج"</string> - <string name="app_label" msgid="9035307001052716210">"میڈیا اسٹوریج"</string> - <string name="picker_app_label" msgid="4254039089502164761">"میڈیا"</string> + <string name="picker_app_label" msgid="1195424381053599122">"میڈیا منتخب کنندہ"</string> <string name="artist_label" msgid="8105600993099120273">"فنکار"</string> <string name="unknown" msgid="2059049215682829375">"نامعلوم"</string> <string name="root_images" msgid="5861633549189045666">"تصاوير"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"اس سے کلاؤڈ میڈیا تک رسائی حاصل کریں"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"کوئی نہیں"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"کلاؤڈ میڈیا ایپ کو اس وقت تبدیل نہیں کیا جا سکا۔"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"میڈیا منتخب کنندہ"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"میڈیا منتخب کنندہ"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"میڈیا کی مطابقت پذیری کی جا رہی ہے…"</string> <string name="add" msgid="2894574044585549298">"شامل کریں"</string> <string name="deselect" msgid="4297825044827769490">"غیر منتخب کریں"</string> <string name="deselected" msgid="8488133193326208475">"غیر منتخب کردہ"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"کوئی البم نہیں"</string> <string name="picker_view_selected" msgid="2266031384396143883">"منتخب کردہ دیکھیں"</string> <string name="picker_photos" msgid="7415035516411087392">"تصاویر"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"البمز"</string> <string name="picker_preview" msgid="6257414886055861039">"پیش منظر"</string> <string name="picker_work_profile" msgid="2083221066869141576">"کام پر سوئچ کریں"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> آئٹم}other{<xliff:g id="COUNT_1">^1</xliff:g> آئٹمز}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"(<xliff:g id="COUNT">^1</xliff:g>) شامل کریں"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"(<xliff:g id="COUNT">^1</xliff:g>) کو اجازت دیں"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"کسی کو اجازت نہ دیں"</string> <string name="picker_category_camera" msgid="4857367052026843664">"کیمرا"</string> <string name="picker_category_downloads" msgid="793866660287361900">"ڈاؤن لوڈز"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"پسندیدہ"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"ویڈیو چلانے میں دشواری"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"اپنا انٹرنیٹ کنکشن چیک کریں اور دوبارہ کوشش کریں"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"پھر کوشش کریں"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"کلاؤڈ میڈیا اب <xliff:g id="PKG_NAME">%1$s</xliff:g> سے دستیاب ہے"</string> <string name="not_selected" msgid="2244008151669896758">"غیر منتخب کردہ"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"آپ کا منتخب کردہ میڈیا تیار کیا جا رہا ہے"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> میں سے <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> تیار ہیں"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"منسوخ کریں"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"بیک اپ لی گئی تصاویر اب شامل ہیں"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"آپ <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> کے <xliff:g id="APP_NAME">%1$s</xliff:g> اکاؤنٹ سے تصاویر منتخب کر سکتے ہیں"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> اکاؤنٹ اپ ڈیٹ کیا گیا"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"ایپ منتخب کریں"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"اکاؤنٹ منتخب کریں"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"اکاؤنٹ تبدیل کریں"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"آپ کی تمام تصاویر حاصل کی جا رہی ہیں"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> کو اس آڈیو فائل میں ترمیم کرنے کی اجازت دیں؟}other{<xliff:g id="APP_NAME_1">^1</xliff:g> کو <xliff:g id="COUNT">^2</xliff:g> آڈیو فائلز میں ترمیم کرنے کی اجازت دیں؟}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{آڈیو فائل میں ترمیم کی جا رہی ہے…}other{<xliff:g id="COUNT">^1</xliff:g> آڈیو فائلز میں ترمیم کی جا رہی ہے…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> کو اس ویڈیو میں ترمیم کرنے کی اجازت دیں؟}other{<xliff:g id="APP_NAME_1">^1</xliff:g> کو <xliff:g id="COUNT">^2</xliff:g> ویڈیوز میں ترمیم کرنے کی اجازت دیں؟}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"سیفٹی پروٹیکشن"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"مقامی ٹرانسکوڈ کے الرٹس"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"مقامی ٹرانسکوڈ کی پیشرفت"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"بعد میں دوبارہ کوشش کریں۔ مسئلہ حل ہو جانے کے بعد آپ کی تصاویر دستیاب ہوں گی۔"</string> + <string name="dialog_error_title" msgid="636349284077820636">"کچھ تصاویر لوڈ نہیں کی جا سکتیں"</string> + <string name="dialog_button_text" msgid="351366485240852280">"سمجھ آ گئی"</string> </resources> diff --git a/res/values-uz/strings.xml b/res/values-uz/strings.xml index 512bae3d5..ac34311a0 100644 --- a/res/values-uz/strings.xml +++ b/res/values-uz/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Multimedia"</string> <string name="storage_description" msgid="4081716890357580107">"Mahalliy xotira"</string> - <string name="app_label" msgid="9035307001052716210">"Multimedia xotirasi"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Media"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Rasm tanlash"</string> <string name="artist_label" msgid="8105600993099120273">"Ijrochi"</string> <string name="unknown" msgid="2059049215682829375">"Noaniq"</string> <string name="root_images" msgid="5861633549189045666">"Rasmlar"</string> @@ -46,22 +45,27 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Bulutli media kontentni ochish"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Hech qanday"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Hozirda bulutli media ilovasi oʻzgarmadi"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Rasm tanlash"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Rasm tanlash"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Media sinxronlanmoqda…"</string> <string name="add" msgid="2894574044585549298">"Kiritish"</string> <string name="deselect" msgid="4297825044827769490">"Tanlovni bekor qilish"</string> <string name="deselected" msgid="8488133193326208475">"Tanlovi yechilgan"</string> <string name="select" msgid="2704765470563027689">"Tanlash"</string> <string name="selected" msgid="9151797369975828124">"Tanlangan"</string> - <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> tagcha elementni tanlang}other{<xliff:g id="COUNT_1">^1</xliff:g> tagcha elementni tanlang}}"</string> + <string name="select_up_to" msgid="6994294169508439957">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> tagacha elementni tanlang}other{<xliff:g id="COUNT_1">^1</xliff:g> tagacha elementni tanlang}}"</string> <string name="recent" msgid="6694613584743207874">"Oxirgi"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"Surat yoki video kiritilmagan"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Qabul qilinmaydigan rasm va videolar"</string> <string name="picker_albums_empty_message" msgid="8341079772950966815">"Albom kiritilmagan"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Belgilanganlarni ochish"</string> <string name="picker_photos" msgid="7415035516411087392">"Suratlar"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Albomlar"</string> <string name="picker_preview" msgid="6257414886055861039">"Razm solish"</string> - <string name="picker_work_profile" msgid="2083221066869141576">"Ish profiliga oʻtish"</string> - <string name="picker_personal_profile" msgid="639484258397758406">"Shaxsiy profilga oʻtish"</string> + <string name="picker_work_profile" msgid="2083221066869141576">"Ish profiliga almashish"</string> + <string name="picker_personal_profile" msgid="639484258397758406">"Shaxsiy profilga almashish"</string> <string name="picker_profile_admin_title" msgid="4172022376418293777">"Administratoringiz tomonidan bloklangan"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Shaxsiy ilovadan ishga oid maʼlumotlarga kirish taqiqlangan"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Ishga oid ilovadan shaxsiy maʼlumotlarga kirish taqiqlangan"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> ta narsa}other{<xliff:g id="COUNT_1">^1</xliff:g> ta narsa}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Kiritish (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Ruxsat (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Hech qanday ruxsat berilmasin"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Kamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Yuklanmalar"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Sevimlilar"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Video ijrosida muammo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Internet aloqasini tekshiring va qayta urining"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Qayta urinish"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Endi <xliff:g id="PKG_NAME">%1$s</xliff:g> bulutli media kontenti mavjud"</string> <string name="not_selected" msgid="2244008151669896758">"tanlanmagan"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Tanlangan media tayyorlanmoqda"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> tayyor"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Bekor qilish"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Zaxiralangan suratlar qoʻshildi"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"<xliff:g id="USER_ACCOUNT">%2$s</xliff:g> hisobidagi <xliff:g id="APP_NAME">%1$s</xliff:g> rasmlarini tanlashingiz mumkin"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g> hisobi yangilandi"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Ilovani tanlash"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Hisobni tanlang"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Hisobni almashtirish"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Barcha rasmlaringizni yuklab oling"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ilovasiga bu audio faylni oʻzgartirishi uchun ruxsat berilsinmi?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Audio fayl oʻzgartirilmoqda…}other{<xliff:g id="COUNT">^1</xliff:g> ta audio fayl oʻzgartirilmoqda…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{<xliff:g id="APP_NAME_0">^1</xliff:g> ilovasiga bu videoni oʻzgartirishi uchun ruxsat berilsinmi?}other{<xliff:g id="APP_NAME_1">^1</xliff:g> ilovasiga <xliff:g id="COUNT">^2</xliff:g> ta videoni oʻzgartirishi uchun ruxsat berilsinmi?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Xavfsizlik himoyasi"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Nativ transkodlash signallari"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Nativ transkodlash jarayoni"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Keyinroq qayta urining. Suratlaringiz muammo hal boʻlgandan keyin chiqadi."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Ayrim suratlar yuklanmadi"</string> + <string name="dialog_button_text" msgid="351366485240852280">"OK"</string> </resources> diff --git a/res/values-v31/styles.xml b/res/values-v31/styles.xml index 3dec1da72..3992eba97 100644 --- a/res/values-v31/styles.xml +++ b/res/values-v31/styles.xml @@ -14,7 +14,8 @@ limitations under the License. --> -<resources xmlns:android="http://schemas.android.com/apk/res/android"> +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> <style name="PickerMaterialTheme" parent="@style/Theme.Material3.DayNight.NoActionBar"> <item name="materialAlertDialogTheme">@style/ProfileDialogTheme</item> @@ -40,6 +41,8 @@ <item name="pickerBannerPrimaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerSecondaryTextColor">?android:attr/textColorPrimary</item> <item name="pickerBannerButtonTextColor">@android:color/system_accent1_600</item> + <item name="categoryDefaultThumbnailColor">?attr/colorOnSurfaceVariant</item> + <item name="categoryDefaultThumbnailCircleColor">?attr/colorSurfaceVariant</item> </style> </resources> diff --git a/res/values-vi/strings.xml b/res/values-vi/strings.xml index d7b3b22ff..ae0b4e53f 100644 --- a/res/values-vi/strings.xml +++ b/res/values-vi/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Phương tiện"</string> <string name="storage_description" msgid="4081716890357580107">"Bộ nhớ cục bộ"</string> - <string name="app_label" msgid="9035307001052716210">"Bộ nhớ phương tiện"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Nội dung nghe nhìn"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Công cụ chọn nội dung đa phương tiện"</string> <string name="artist_label" msgid="8105600993099120273">"Nghệ sĩ"</string> <string name="unknown" msgid="2059049215682829375">"Không xác định"</string> <string name="root_images" msgid="5861633549189045666">"Hình ảnh"</string> @@ -39,13 +38,16 @@ <string name="allow" msgid="8885707816848569619">"Cho phép"</string> <string name="deny" msgid="6040983710442068936">"Từ chối"</string> <string name="picker_browse" msgid="5554477454636075934">"Duyệt qua…"</string> - <string name="picker_settings" msgid="6443463167344790260">"Ứng dụng đa phương tiện trên đám mây"</string> - <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Ứng dụng đa phương tiện trên đám mây"</string> - <string name="picker_settings_title" msgid="5647700706470673258">"Ứng dụng nội dung phương tiện trên đám mây"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"Truy cập vào nội dung nghe nhìn trên đám mây của bạn khi ứng dụng hoặc trang web yêu cầu bạn chọn ảnh hoặc video"</string> - <string name="picker_settings_selection_message" msgid="245453573086488596">"Truy cập nội dung phương tiện trên đám mây qua"</string> + <string name="picker_settings" msgid="6443463167344790260">"Ứng dụng nghe nhìn trên đám mây"</string> + <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"Ứng dụng nghe nhìn trên đám mây"</string> + <string name="picker_settings_title" msgid="5647700706470673258">"Ứng dụng nghe nhìn trên đám mây"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"Truy cập vào nội dung nghe nhìn của bạn trên đám mây khi có ứng dụng hay trang web yêu cầu bạn chọn ảnh hoặc video"</string> + <string name="picker_settings_selection_message" msgid="245453573086488596">"Truy cập nội dung nghe nhìn trên đám mây qua"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Không có"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Hiện không thay đổi được ứng dụng đa phương tiện đám mây."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Công cụ chọn nội dung đa phương tiện"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Công cụ chọn nội dung đa phương tiện"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Đang đồng bộ hoá nội dung đa phương tiện…"</string> <string name="add" msgid="2894574044585549298">"Thêm"</string> <string name="deselect" msgid="4297825044827769490">"Bỏ chọn"</string> <string name="deselected" msgid="8488133193326208475">"Đã bỏ chọn"</string> @@ -55,14 +57,16 @@ <string name="recent" msgid="6694613584743207874">"Gần đây"</string> <string name="picker_photos_empty_message" msgid="5980619500554575558">"Không có ảnh hoặc video nào"</string> <string name="picker_album_media_empty_message" msgid="7061850698189881671">"Không có ảnh hoặc video nào được hỗ trợ"</string> - <string name="picker_albums_empty_message" msgid="8341079772950966815">"Không có đĩa nhạc nào"</string> + <string name="picker_albums_empty_message" msgid="8341079772950966815">"Không có album nào"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Xem các mục được chọn"</string> <string name="picker_photos" msgid="7415035516411087392">"Ảnh"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Album"</string> <string name="picker_preview" msgid="6257414886055861039">"Xem trước"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Chuyển sang hồ sơ công việc"</string> <string name="picker_personal_profile" msgid="639484258397758406">"Chuyển sang hồ sơ cá nhân"</string> - <string name="picker_profile_admin_title" msgid="4172022376418293777">"Bị quản trị viên của bạn chặn"</string> + <string name="picker_profile_admin_title" msgid="4172022376418293777">"Quản trị viên của bạn đã chặn thao tác này"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Bạn không được phép truy cập dữ liệu công việc từ một ứng dụng cá nhân"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Bạn không được phép truy cập dữ liệu cá nhân từ một ứng dụng công việc"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Ứng dụng công việc đã tạm dừng"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> mục}other{<xliff:g id="COUNT_1">^1</xliff:g> mục}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Thêm (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Cho phép (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Không cho phép"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Máy ảnh"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Tệp đã tải xuống"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Mục yêu thích"</string> @@ -92,10 +97,11 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Sự cố khi phát video"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Hãy kiểm tra kết nối Internet rồi thử lại"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Thử lại"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Hiện đã có phương tiện đám mây từ <xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"chưa được chọn"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Đang chuẩn bị nội dung đa phương tiện mà bạn chọn"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g>/<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> mục đã sẵn sàng"</string> - <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ảnh được sao lưu giờ đã xuất hiện"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Huỷ"</string> + <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Ảnh được sao lưu giờ đã có mặt"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Bạn có thể chọn ảnh của <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> trên <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"Đã cập nhật tài khoản <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"Ảnh của <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> giờ sẽ xuất hiện tại đây"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Chọn ứng dụng"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Chọn tài khoản"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Thay đổi tài khoản"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Tải tất cả các ảnh"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Cho phép <xliff:g id="APP_NAME_0">^1</xliff:g> sửa đổi tệp âm thanh này?}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?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Đang sửa đổi tệp âm thanh…}other{Đang sửa đổi <xliff:g id="COUNT">^1</xliff:g> tệp âm thanh…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Cho phép <xliff:g id="APP_NAME_0">^1</xliff:g> sửa đổi video này?}other{Cho phép <xliff:g id="APP_NAME_1">^1</xliff:g> sửa đổi <xliff:g id="COUNT">^2</xliff:g> video?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Bảo vệ an toàn"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Cảnh báo chuyển mã gốc"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Tiến trình chuyển mã gốc"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Hãy thử lại sau. Ảnh của bạn sẽ xuất hiện sau khi vấn đề được giải quyết."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Không tải được một số ảnh"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Tôi hiểu"</string> </resources> diff --git a/res/values-watch/dimens.xml b/res/values-watch/dimens.xml deleted file mode 100644 index ed5fa0032..000000000 --- a/res/values-watch/dimens.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?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. ---> - -<resources> - <dimen name="permission_dialog_width">200dp</dimen> -</resources> diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml index 2ad13a400..a2c02a217 100644 --- a/res/values-zh-rCN/strings.xml +++ b/res/values-zh-rCN/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"媒体"</string> <string name="storage_description" msgid="4081716890357580107">"本地存储空间"</string> - <string name="app_label" msgid="9035307001052716210">"媒体存储设备"</string> - <string name="picker_app_label" msgid="4254039089502164761">"媒体"</string> + <string name="picker_app_label" msgid="1195424381053599122">"媒体选择工具"</string> <string name="artist_label" msgid="8105600993099120273">"音乐人"</string> <string name="unknown" msgid="2059049215682829375">"未知"</string> <string name="root_images" msgid="5861633549189045666">"图片"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"从以下位置访问云端媒体:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"无"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"目前无法更改云端媒体应用。"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"媒体选择工具"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"媒体选择工具"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"正在同步媒体…"</string> <string name="add" msgid="2894574044585549298">"添加"</string> <string name="deselect" msgid="4297825044827769490">"取消选择"</string> <string name="deselected" msgid="8488133193326208475">"已取消选中"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"无影集"</string> <string name="picker_view_selected" msgid="2266031384396143883">"查看所选内容"</string> <string name="picker_photos" msgid="7415035516411087392">"照片"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"影集"</string> <string name="picker_preview" msgid="6257414886055861039">"预览"</string> <string name="picker_work_profile" msgid="2083221066869141576">"切换到工作资料"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> 个}other{<xliff:g id="COUNT_1">^1</xliff:g> 个}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"添加(<xliff:g id="COUNT">^1</xliff:g> 项)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"允许 (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"全部不允许"</string> <string name="picker_category_camera" msgid="4857367052026843664">"相机"</string> <string name="picker_category_downloads" msgid="793866660287361900">"下载内容"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"收藏"</string> @@ -92,23 +97,23 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"播放视频时遇到问题"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"请检查互联网连接,然后重试"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"重试"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"现在可以从“<xliff:g id="PKG_NAME">%1$s</xliff:g>”获取云端媒体"</string> <string name="not_selected" msgid="2244008151669896758">"未选择"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"正在准备您选择的媒体"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> 个已准备就绪,共 <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> 个"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"取消"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"备份照片现已添加完成"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"您可以选择来自<xliff:g id="APP_NAME">%1$s</xliff:g>帐号 <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> 的照片"</string> - <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>帐号已更新"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"您可以选择来自<xliff:g id="APP_NAME">%1$s</xliff:g>账号 <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> 的照片"</string> + <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"<xliff:g id="APP_NAME">%1$s</xliff:g>账号已更新"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"来自 <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> 的照片已添加到此处"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"选择云端媒体应用"</string> <string name="picker_banner_cloud_choose_app_desc" msgid="2359212653555524926">"如需将备份照片添加到此处,请在“设置”中选择一个云端媒体应用"</string> - <string name="picker_banner_cloud_choose_account_title" msgid="5010901185639577685">"选择<xliff:g id="APP_NAME">%1$s</xliff:g>帐号"</string> - <string name="picker_banner_cloud_choose_account_desc" msgid="8868134443673142712">"如需将来自<xliff:g id="APP_NAME">%1$s</xliff:g>的照片添加到此处,请在应用中选择一个帐号"</string> + <string name="picker_banner_cloud_choose_account_title" msgid="5010901185639577685">"选择<xliff:g id="APP_NAME">%1$s</xliff:g>账号"</string> + <string name="picker_banner_cloud_choose_account_desc" msgid="8868134443673142712">"如需将来自<xliff:g id="APP_NAME">%1$s</xliff:g>的照片添加到此处,请在应用中选择一个账号"</string> <string name="picker_banner_cloud_dismiss_button" msgid="2935903078288463882">"关闭"</string> <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"选择应用"</string> - <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"选择帐号"</string> - <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"更改帐号"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"选择账号"</string> + <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"更改账号"</string> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"正在获取您的所有照片"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{要允许<xliff:g id="APP_NAME_0">^1</xliff:g>修改这个音频文件吗?}other{要允许<xliff:g id="APP_NAME_1">^1</xliff:g>修改这 <xliff:g id="COUNT">^2</xliff:g> 个音频文件吗?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{正在修改音频文件…}other{正在修改 <xliff:g id="COUNT">^1</xliff:g> 个音频文件…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{要允许<xliff:g id="APP_NAME_0">^1</xliff:g>修改这个视频吗?}other{要允许<xliff:g id="APP_NAME_1">^1</xliff:g>修改这 <xliff:g id="COUNT">^2</xliff:g> 个视频吗?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"安全保护"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"原生转码警报"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"原生转码进度"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"请稍后再试。问题解决后,您就能看到这些照片了。"</string> + <string name="dialog_error_title" msgid="636349284077820636">"部分照片无法加载"</string> + <string name="dialog_button_text" msgid="351366485240852280">"知道了"</string> </resources> diff --git a/res/values-zh-rHK/strings.xml b/res/values-zh-rHK/strings.xml index 2e84e711c..fa754947e 100644 --- a/res/values-zh-rHK/strings.xml +++ b/res/values-zh-rHK/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"媒體"</string> <string name="storage_description" msgid="4081716890357580107">"本機儲存空間"</string> - <string name="app_label" msgid="9035307001052716210">"媒體儲存空間"</string> - <string name="picker_app_label" msgid="4254039089502164761">"媒體"</string> + <string name="picker_app_label" msgid="1195424381053599122">"媒體選擇器"</string> <string name="artist_label" msgid="8105600993099120273">"歌手"</string> <string name="unknown" msgid="2059049215682829375">"不明"</string> <string name="root_images" msgid="5861633549189045666">"相片"</string> @@ -42,10 +41,13 @@ <string name="picker_settings" msgid="6443463167344790260">"雲端媒體應用程式"</string> <string name="picker_settings_system_settings_menu_title" msgid="3055084757610063581">"雲端媒體應用程式"</string> <string name="picker_settings_title" msgid="5647700706470673258">"雲端媒體應用程式"</string> - <string name="picker_settings_description" msgid="2916686824777214585">"當應用程式或網站要求您選取相片或影片時,就可使用自己的雲端媒體"</string> + <string name="picker_settings_description" msgid="2916686824777214585">"當應用程式或網站要求你選取相片或影片時,就可使用自己的雲端媒體"</string> <string name="picker_settings_selection_message" msgid="245453573086488596">"從以下位置存取雲端媒體:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"無"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"目前無法變更雲端媒體應用程式。"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"媒體選擇器"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"媒體選擇器"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"正在同步媒體…"</string> <string name="add" msgid="2894574044585549298">"新增"</string> <string name="deselect" msgid="4297825044827769490">"取消選取"</string> <string name="deselected" msgid="8488133193326208475">"已取消選取"</string> @@ -58,20 +60,23 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"沒有相簿"</string> <string name="picker_view_selected" msgid="2266031384396143883">"查看所選項目"</string> <string name="picker_photos" msgid="7415035516411087392">"相片"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"相簿"</string> <string name="picker_preview" msgid="6257414886055861039">"預覽"</string> <string name="picker_work_profile" msgid="2083221066869141576">"切換至工作設定檔"</string> <string name="picker_personal_profile" msgid="639484258397758406">"切換至個人設定檔"</string> - <string name="picker_profile_admin_title" msgid="4172022376418293777">"管理員已禁止此操作"</string> + <string name="picker_profile_admin_title" msgid="4172022376418293777">"管理員禁止此操作"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"個人應用程式不得存取工作資料"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"工作應用程式不得存取個人資料"</string> <string name="picker_profile_work_paused_title" msgid="382212880704235925">"已暫停工作應用程式"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"如要開啟工作相片,請開啟工作應用程式,然後再試一次"</string> - <string name="picker_privacy_message" msgid="9132700451027116817">"此應用程式只能存取您選取的相片"</string> + <string name="picker_privacy_message" msgid="9132700451027116817">"此應用程式只能存取你選取的相片"</string> <string name="picker_header_permissions" msgid="675872774407768495">"選擇允許此應用程式存取的相片和影片"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> 個項目}other{<xliff:g id="COUNT_1">^1</xliff:g> 個項目}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"新增 (<xliff:g id="COUNT">^1</xliff:g> 個)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"允許 (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"全部禁止"</string> <string name="picker_category_camera" msgid="4857367052026843664">"相機"</string> <string name="picker_category_downloads" msgid="793866660287361900">"下載"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"我的最愛"</string> @@ -90,13 +95,14 @@ <string name="picker_pause_video" msgid="1092718225234326702">"暫停"</string> <string name="picker_error_snackbar" msgid="5970192792792369203">"無法播放影片"</string> <string name="picker_error_dialog_title" msgid="4540095603788920965">"播放影片時發生問題"</string> - <string name="picker_error_dialog_body" msgid="2515738446802971453">"請檢查您的互聯網連線,然後再試一次"</string> + <string name="picker_error_dialog_body" msgid="2515738446802971453">"請檢查你的互聯網連線,然後再試一次"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"重試"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"現可透過「<xliff:g id="PKG_NAME">%1$s</xliff:g>」使用雲端媒體"</string> <string name="not_selected" msgid="2244008151669896758">"未揀"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"正在準備你選取的媒體"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> 個項目已就緒,共 <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> 個"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"取消"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"現在已納入備份相片"</string> - <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"您可從「<xliff:g id="APP_NAME">%1$s</xliff:g>」帳戶 <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> 選取相片"</string> + <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"你可從「<xliff:g id="APP_NAME">%1$s</xliff:g>」帳戶 <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> 選取相片"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"「<xliff:g id="APP_NAME">%1$s</xliff:g>」帳戶更新完成"</string> <string name="picker_banner_cloud_account_changed_desc" msgid="3433218869899792497">"現在亦會在此處納入 <xliff:g id="USER_ACCOUNT">%1$s</xliff:g> 的相片"</string> <string name="picker_banner_cloud_choose_app_title" msgid="3165966147547974251">"選擇雲端媒體應用程式"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"安全保護"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"原生轉碼警示"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"原生轉碼進度"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"請稍後再試。相片會在問題解決後顯示。"</string> + <string name="dialog_error_title" msgid="636349284077820636">"部分相片無法載入"</string> + <string name="dialog_button_text" msgid="351366485240852280">"知道了"</string> </resources> diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml index f3263c90c..63c34015b 100644 --- a/res/values-zh-rTW/strings.xml +++ b/res/values-zh-rTW/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"媒體"</string> <string name="storage_description" msgid="4081716890357580107">"本機儲存空間"</string> - <string name="app_label" msgid="9035307001052716210">"媒體儲存空間"</string> - <string name="picker_app_label" msgid="4254039089502164761">"媒體"</string> + <string name="picker_app_label" msgid="1195424381053599122">"媒體選擇器"</string> <string name="artist_label" msgid="8105600993099120273">"演出者"</string> <string name="unknown" msgid="2059049215682829375">"不明"</string> <string name="root_images" msgid="5861633549189045666">"圖片"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"從以下位置存取雲端媒體:"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"無"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"目前無法變更雲端媒體應用程式。"</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"媒體選擇器"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"媒體選擇器"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"正在同步媒體…"</string> <string name="add" msgid="2894574044585549298">"新增"</string> <string name="deselect" msgid="4297825044827769490">"取消選取"</string> <string name="deselected" msgid="8488133193326208475">"已取消選取"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"沒有相簿"</string> <string name="picker_view_selected" msgid="2266031384396143883">"查看所選項目"</string> <string name="picker_photos" msgid="7415035516411087392">"相片"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"相簿"</string> <string name="picker_preview" msgid="6257414886055861039">"預覽"</string> <string name="picker_work_profile" msgid="2083221066869141576">"切換至工作資料夾"</string> @@ -72,6 +76,7 @@ <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{<xliff:g id="COUNT_0">^1</xliff:g> 個項目}other{<xliff:g id="COUNT_1">^1</xliff:g> 個項目}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"新增 (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"允許 (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"全部禁止"</string> <string name="picker_category_camera" msgid="4857367052026843664">"相機"</string> <string name="picker_category_downloads" msgid="793866660287361900">"下載的內容"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"收藏的內容"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"播放影片時發生問題"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"請檢查網際網路連線,然後再試一次"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"重試"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"現在可以透過「<xliff:g id="PKG_NAME">%1$s</xliff:g>」存取雲端媒體"</string> <string name="not_selected" msgid="2244008151669896758">"未選取"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"正在準備所選媒體"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"已備妥 <xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> 個項目,共 <xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> 個項目"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"取消"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"現在已納入備份相片"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"你可以從「<xliff:g id="APP_NAME">%1$s</xliff:g>」帳戶 <xliff:g id="USER_ACCOUNT">%2$s</xliff:g> 選取相片"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"「<xliff:g id="APP_NAME">%1$s</xliff:g>」帳戶更新完成"</string> @@ -151,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"安全防護"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"原生轉碼警示"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"原生轉碼進度"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"請稍後再試。問題解決後,你就可以存取相片。"</string> + <string name="dialog_error_title" msgid="636349284077820636">"無法載入部分相片"</string> + <string name="dialog_button_text" msgid="351366485240852280">"我知道了"</string> </resources> diff --git a/res/values-zu/strings.xml b/res/values-zu/strings.xml index ab427ed18..c0a63ae82 100644 --- a/res/values-zu/strings.xml +++ b/res/values-zu/strings.xml @@ -18,8 +18,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="uid_label" msgid="8421971615411294156">"Abezind"</string> <string name="storage_description" msgid="4081716890357580107">"Isitoreji sasendaweni"</string> - <string name="app_label" msgid="9035307001052716210">"Isitoreji Semidiya"</string> - <string name="picker_app_label" msgid="4254039089502164761">"Imidiya"</string> + <string name="picker_app_label" msgid="1195424381053599122">"Isikhethi semidiya"</string> <string name="artist_label" msgid="8105600993099120273">"Umculi"</string> <string name="unknown" msgid="2059049215682829375">"Akwaziwa"</string> <string name="root_images" msgid="5861633549189045666">"Izithombe"</string> @@ -46,6 +45,9 @@ <string name="picker_settings_selection_message" msgid="245453573086488596">"Finyelela imidiya yacloud ukusuka"</string> <string name="picker_settings_no_provider" msgid="2582311853680058223">"Lutho"</string> <string name="picker_settings_toast_error" msgid="697274445512467469">"Ayikwazanga ukushintsha i-app yemidiya ye-cloud manje."</string> + <string name="picker_sync_notification_channel" msgid="1867105708912627993">"Isikhethi semidiya"</string> + <string name="picker_sync_notification_title" msgid="1122713382122055246">"Isikhethi semidiya"</string> + <string name="picker_sync_notification_text" msgid="8204423917712309382">"Ivumelanisa imidiya…"</string> <string name="add" msgid="2894574044585549298">"Engeza"</string> <string name="deselect" msgid="4297825044827769490">"Susa ukukhetha"</string> <string name="deselected" msgid="8488133193326208475">"Okususwe ekukhethweni"</string> @@ -58,6 +60,8 @@ <string name="picker_albums_empty_message" msgid="8341079772950966815">"Awekho ama-albhamu"</string> <string name="picker_view_selected" msgid="2266031384396143883">"Ukubuka kukhethiwe"</string> <string name="picker_photos" msgid="7415035516411087392">"Izithombe"</string> + <!-- no translation found for picker_videos (2886971435439047097) --> + <skip /> <string name="picker_albums" msgid="4822511902115299142">"Ama-albhamu"</string> <string name="picker_preview" msgid="6257414886055861039">"Hlola kuqala"</string> <string name="picker_work_profile" msgid="2083221066869141576">"Shintshela kokmsebenzi"</string> @@ -65,13 +69,14 @@ <string name="picker_profile_admin_title" msgid="4172022376418293777">"Kuvinjwe ngumphathi wakho"</string> <string name="picker_profile_admin_msg_from_personal" msgid="1941639895084555723">"Ukufinyelela idatha evela ku-app yomuntu siqu akuvunyelwe"</string> <string name="picker_profile_admin_msg_from_work" msgid="8048524337462790110">"Ukufinyelela idatha yomuntu siqu evela ku-app yomsebenzi akuvunyelwe"</string> - <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Ama-app okusebenza aphunyuziwe"</string> + <string name="picker_profile_work_paused_title" msgid="382212880704235925">"Ama-app okusebenza amisiwe"</string> <string name="picker_profile_work_paused_msg" msgid="6321552322125246726">"Ukuze uvule izithombe zomsebenzi, vula ama-app wakho womsebenzi bese uzama futhi"</string> <string name="picker_privacy_message" msgid="9132700451027116817">"Le app ingafinyelela izithombe ozikhethayo kuphela"</string> <string name="picker_header_permissions" msgid="675872774407768495">"Khetha izithombe namavidiyo ovumela le app ukuthi iwafinyelele"</string> <string name="picker_album_item_count" msgid="4420723302534177596">"{count,plural, =1{into <xliff:g id="COUNT_0">^1</xliff:g>}one{izinto <xliff:g id="COUNT_1">^1</xliff:g>}other{izinto <xliff:g id="COUNT_1">^1</xliff:g>}}"</string> <string name="picker_add_button_multi_select" msgid="4005164092275518399">"Engeza (<xliff:g id="COUNT">^1</xliff:g>)"</string> <string name="picker_add_button_multi_select_permissions" msgid="5138751105800138838">"Vumela (<xliff:g id="COUNT">^1</xliff:g>)"</string> + <string name="picker_add_button_allow_none_option" msgid="9183772732922241035">"Ungavumeli lutho"</string> <string name="picker_category_camera" msgid="4857367052026843664">"Ikhamera"</string> <string name="picker_category_downloads" msgid="793866660287361900">"Okulandiwe"</string> <string name="picker_category_favorites" msgid="7008495397818966088">"Izintandokazi"</string> @@ -92,9 +97,10 @@ <string name="picker_error_dialog_title" msgid="4540095603788920965">"Inkinga yokudlala ividiyo"</string> <string name="picker_error_dialog_body" msgid="2515738446802971453">"Hlola ukuxhuma kwakho kwe-inthanethi uphinde uzame futhi"</string> <string name="picker_error_dialog_positive_action" msgid="749544129082109232">"Zama futhi"</string> - <string name="picker_cloud_sync" msgid="997251377538536319">"Imidiya ye-cloud manje iyatholakala kusuka ku-<xliff:g id="PKG_NAME">%1$s</xliff:g>"</string> <string name="not_selected" msgid="2244008151669896758">"akukhethiwe"</string> + <string name="preloading_dialog_title" msgid="4974348221848532887">"Ilungiselela imidiya yakho oyikhethile"</string> <string name="preloading_progress_message" msgid="4741327138031980582">"U-<xliff:g id="NUMBER_PRELOADED">%1$d</xliff:g> wokungu-<xliff:g id="NUMBER_TOTAL">%2$d</xliff:g> ulungile"</string> + <string name="preloading_cancel_button" msgid="824053521307342209">"Khansela"</string> <string name="picker_banner_cloud_first_time_available_title" msgid="5912973744275711595">"Izithombe ezenziwe isipele sezifakiwe manje"</string> <string name="picker_banner_cloud_first_time_available_desc" msgid="5570916598348187607">"Ungakhetha izithombe ezivela ku-akhawunti ye-<xliff:g id="APP_NAME">%1$s</xliff:g> ethi <xliff:g id="USER_ACCOUNT">%2$s</xliff:g>"</string> <string name="picker_banner_cloud_account_changed_title" msgid="4825058474378077327">"I-akhawunti ye-<xliff:g id="APP_NAME">%1$s</xliff:g> ibuyekeziwe"</string> @@ -107,8 +113,7 @@ <string name="picker_banner_cloud_choose_app_button" msgid="934085679890435479">"Khetha i-app"</string> <string name="picker_banner_cloud_choose_account_button" msgid="7979484877116991631">"Khetha i-akhawunti"</string> <string name="picker_banner_cloud_change_account_button" msgid="8361239765828471146">"Shintsha i-akhawunti"</string> - <!-- no translation found for picker_loading_photos_message (6449180084857178949) --> - <skip /> + <string name="picker_loading_photos_message" msgid="6449180084857178949">"Ithola zonke izithombe zakho"</string> <string name="permission_write_audio" msgid="8819694245323580601">"{count,plural, =1{Vumela i-<xliff:g id="APP_NAME_0">^1</xliff:g> ukuguqula leli fayela lomsindo?}one{Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amafayela omsindo angu-<xliff:g id="COUNT">^2</xliff:g>?}other{Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amafayela omsindo angu-<xliff:g id="COUNT">^2</xliff:g>?}}"</string> <string name="permission_progress_write_audio" msgid="6029375427984180097">"{count,plural, =1{Ilungisa ifayela lomsindo…}one{Ilungisa amafayela womsindo angu-<xliff:g id="COUNT">^1</xliff:g>…}other{Ilungisa amafayela womsindo angu-<xliff:g id="COUNT">^1</xliff:g>…}}"</string> <string name="permission_write_video" msgid="103902551603700525">"{count,plural, =1{Vumela i-<xliff:g id="APP_NAME_0">^1</xliff:g> ukuguqula le vidiyo?}one{Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amavidiyo angu-<xliff:g id="COUNT">^2</xliff:g>?}other{Vumela i-<xliff:g id="APP_NAME_1">^1</xliff:g> ukuguqula amavidiyo angu-<xliff:g id="COUNT">^2</xliff:g>?}}"</string> @@ -152,4 +157,7 @@ <string name="safety_protection_icon_label" msgid="6714354052747723623">"Ukuvikeleka kokuphepha"</string> <string name="transcode_alert_channel" msgid="997332371757680478">"Izexwayiso Zokudlulisela Ikhodi Yomdabu"</string> <string name="transcode_progress_channel" msgid="6905136787933058387">"Inqubekela-phambili Yokudlulisela Ikhodi Yomdabu"</string> + <string name="dialog_error_message" msgid="5120432204743681606">"Zama futhi emuva kwesikhathi. Izithombe zakho zizotholakala uma inkinga isixazululiwe."</string> + <string name="dialog_error_title" msgid="636349284077820636">"Ayikwazi ukulayisha ezinye Izithombe"</string> + <string name="dialog_button_text" msgid="351366485240852280">"Ngiyezwa"</string> </resources> diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 6f53ce1bf..c932084b8 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -78,4 +78,10 @@ <!-- Photo Picker Banner button text color. --> <attr name="pickerBannerButtonTextColor" format="reference|color" /> + <!-- Default thumbnail icon color for merged albums --> + <attr name="categoryDefaultThumbnailColor" format="reference|color"/> + + <!-- Default thumbnail ellipse color for merged albums --> + <attr name="categoryDefaultThumbnailCircleColor" format="reference|color" /> + </resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 34762e97f..7681262db 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -15,10 +15,10 @@ --> <resources> - <dimen name="permission_dialog_width">320dp</dimen> <dimen name="permission_thumb_size">64dp</dimen> <dimen name="permission_thumb_margin">6dp</dimen> <dimen name="dialog_space">20dp</dimen> + <dimen name="button_touch_size">48dp</dimen> <!-- PhotoPicker --> <dimen name="picker_top_corner_radius">28dp</dimen> @@ -53,7 +53,7 @@ <dimen name="picker_photo_item_spacing">3dp</dimen> - <!-- Photo Picker recycler view bottom padding for profile button or bottom bar --> + <!-- Photo Picker recycler view bottom padding for progress bar --> <dimen name="picker_recycler_view_bottom_padding">78dp</dimen> <dimen name="picker_tab_text_size">14sp</dimen> @@ -63,6 +63,12 @@ <dimen name="picker_tab_min_width">88dp</dimen> <dimen name="picker_tab_horizontal_gap">4dp</dimen> + <dimen name="picker_tab_loading_message_text_size">11sp</dimen> + + <dimen name="picker_progress_bar_margin_top">15dp</dimen> + <!-- Photo Picker recycler view top padding for profile button or bottom bar --> + <dimen name="picker_recycler_view_top_padding">31dp</dimen> + <dimen name="picker_drag_margin_top">16dp</dimen> <dimen name="picker_drag_margin_bottom">16dp</dimen> diff --git a/res/values/strings.xml b/res/values/strings.xml index 65521bd66..748e7c57a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -21,12 +21,8 @@ <!-- Label to show client applications a short description of storage location --> <string name="storage_description">Local storage</string> - <!-- TODO(b/246345209): This string is unused. Update app_label string to "Media" --> - <!-- and delete picker_app_label --> - <string name="app_label">Media Storage</string> - <!-- Label to show to user for this package and for Photo picker. --> - <string name="picker_app_label">Media</string> + <string name="picker_app_label">Media picker</string> <!-- Description line for music artists in the search/suggestion results --> <string name="artist_label">Artist</string> @@ -107,6 +103,15 @@ <!-- Error message displayed to the user when the user is not able to change cloud media app preference in Picker Settings. [CHAR LIMIT=50] --> <string name="picker_settings_toast_error">Could not change cloud media app at this time.</string> + <!-- PhotoPicker notification channel for sync updates [CHAR LIMIT=40] --> + <string name="picker_sync_notification_channel">Media picker</string> + + <!-- PhotoPicker sync notification title [CHAR LIMIT=40] --> + <string name="picker_sync_notification_title">Media picker</string> + + <!-- PhotoPicker sync notification text [CHAR LIMIT=60] --> + <string name="picker_sync_notification_text">Syncing media…</string> + <!-- Add button for PhotoPicker. [CHAR LIMIT=30] --> <string name="add">Add</string> @@ -138,12 +143,15 @@ <!-- The message for empty message on Albums tab in PhotoPicker when the item count is zero. [CHAR LIMIT=NONE] --> <string name="picker_albums_empty_message">No albums</string> - <!-- PhotoPicker view selected action text. [CHAR LIMIT=80] --> + <!-- PhotoPicker view selected action text. [CHAR LIMIT=17] --> <string name="picker_view_selected">View selected</string> - <!-- The text of the photos tab for PhotoPicker. [CHAR LIMIT=30] --> + <!-- The text of the photos tab in PhotoPicker for 'Image/' mime type. [CHAR LIMIT=30] --> <string name="picker_photos">Photos</string> + <!-- The text of the photos tab in PhotoPicker for 'Video/' mime type. [CHAR LIMIT=30] --> + <string name="picker_videos">@string/root_videos</string> + <!-- The text of the albums tab for PhotoPicker. [CHAR LIMIT=30] --> <string name="picker_albums">Albums</string> @@ -188,6 +196,9 @@ <!-- TODO(b/257208235): Update with finalized UX string. !--> <string name="picker_add_button_multi_select_permissions">Allow (<xliff:g id="count" example="42">^1</xliff:g>)</string> + <!-- Text shown on the add button for multi-select in Picker Choice when no photo is selected.[CHAR LIMIT=30] --> + <string name="picker_add_button_allow_none_option">Allow none</string> + <!-- Title for the category in the picker that offers items in Camera folder. [CHAR LIMIT=24] --> <string name="picker_category_camera">Camera</string> <!-- Title for the category in the picker that offers downloaded items. [CHAR LIMIT=24] --> @@ -241,15 +252,16 @@ <!-- Retriable error dialog positive action button text --> <string name="picker_error_dialog_positive_action">Retry</string> - <!-- Toast notifying user that cloud media content is now available from an app on their device. [CHAR LIMIT=NONE] --> - <string name="picker_cloud_sync">Cloud media now available from <xliff:g id="pkg_name" example="Gmail">%1$s</xliff:g></string> - <!-- Default not selected text used by accessibility for an element that can be unselected. [CHAR LIMIT=NONE] --> <string name="not_selected">not selected</string> + <!-- Title of the preloading progress dialog --> + <string name="preloading_dialog_title">"Preparing your selected media"</string> <!-- A message for the Progress Dialog shown while preloading selected items before "closing" Photo Picker. [CHAR LIMIT=NONE] --> <string name="preloading_progress_message"><xliff:g id="number_preloaded">%1$d</xliff:g> of <xliff:g id="number_total">%2$d</xliff:g> ready</string> + <string name="preloading_cancel_button">Cancel</string> + <!-- ========================= PHOTO PICKER CLOUD EDUCATION BANNERS ========================= --> <!-- Title for the banner notifying the user that the cloud media is now available in the picker [CHAR LIMIT=NONE] --> @@ -288,6 +300,10 @@ <!-- Change account button for banners [CHAR LIMIT=25] --> <string name="picker_banner_cloud_change_account_button">Change account</string> + <!-- A messaged displayed on top of the prgressbar when photos are being loaded. [CHAR LIMIT=80]--> + <string name="picker_loading_photos_message">Getting all your photos</string> + + <!-- ========================= BEGIN AUTO-GENERATED BY gen_strings.py ========================= --> <!-- ========================= WRITE STRINGS ========================= --> @@ -498,4 +514,13 @@ <!-- Transcode progress channel name. --> <string name="transcode_progress_channel">Native Transcode Progress</string> + + <!-- Dialog error message--> + <string name="dialog_error_message">Try again later. Your photos will be available once the issue is resolved.</string> + + <!-- Dialog error title--> + <string name="dialog_error_title">Can\'t load some Photos</string> + + <!-- Error dialog OK button text--> + <string name="dialog_button_text">Got it</string> </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml index d499105f7..55a1f9d66 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -14,7 +14,8 @@ limitations under the License. --> -<resources xmlns:android="http://schemas.android.com/apk/res/android"> +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> <style name="PickerDialogTheme" parent="@android:style/Theme.DeviceDefault.Light.Dialog.Alert"> @@ -89,6 +90,7 @@ <style name="PickerDefaultTheme" parent="@android:style/Theme.DeviceDefault.DayNight"> <!-- System | Widget section --> + <item name="actionOverflowButtonStyle">@style/OverflowButtonStyle</item> <item name="android:backgroundDimEnabled">true</item> <item name="android:navigationBarColor">@color/picker_background_color</item> <item name="android:statusBarColor">@android:color/transparent</item> @@ -103,7 +105,7 @@ <item name="android:listPreferredItemPaddingEnd">@dimen/picker_settings_list_item_padding_end</item> </style> - <style name="PickerMaterialTheme" parent="@style/Theme.MaterialComponents.DayNight.NoActionBar"> + <style name="PickerMaterialTheme" parent="@style/Theme.Material3.DayNight.NoActionBar"> <item name="materialAlertDialogTheme">@style/ProfileDialogTheme</item> <item name="pickerDragBarColor">#DADCE0</item> <item name="pickerHighlightColor">?android:attr/colorAccent</item> @@ -127,6 +129,8 @@ <item name="pickerBannerPrimaryTextColor">?android:attr/textColorSecondary</item> <item name="pickerBannerSecondaryTextColor">?android:attr/textColorPrimary</item> <item name="pickerBannerButtonTextColor">?android:attr/colorAccent</item> + <item name="categoryDefaultThumbnailColor">?attr/colorOnSurfaceVariant</item> + <item name="categoryDefaultThumbnailCircleColor">?attr/colorSurfaceVariant</item> </style> <style name="PickerBannerButtonTheme" @@ -138,4 +142,23 @@ <item name="android:textColor">?attr/pickerBannerButtonTextColor</item> </style> + <style name="OverflowButtonStyle" parent="Widget.AppCompat.ActionButton.Overflow"> + <item name="android:minWidth">@dimen/button_touch_size</item> + </style> + + <style name="SelectedMediaPreloaderDialogTheme" + parent="@style/ThemeOverlay.MaterialComponents.MaterialAlertDialog.Centered"> + <item name="android:textColor">?attr/colorOnSurfaceVariant</item> + <item name="materialAlertDialogTitleTextStyle">@style/AlertDialogTitleStyle</item> + </style> + + <style name="ProgressDialogCancelButtonStyle" + parent="@style/Widget.MaterialComponents.Button.TextButton"> + <item name="android:textColor">?attr/colorOnSurface</item> + </style> + + <style name="AlertDialogTitleStyle" + parent="@style/MaterialAlertDialog.MaterialComponents.Title.Text.CenterStacked"> + <item name="android:textColor">?attr/colorOnSurface</item> + </style> </resources> diff --git a/src/com/android/providers/media/ConfigStore.java b/src/com/android/providers/media/ConfigStore.java index 20a54840f..828d620d7 100644 --- a/src/com/android/providers/media/ConfigStore.java +++ b/src/com/android/providers/media/ConfigStore.java @@ -35,6 +35,7 @@ import androidx.core.util.Supplier; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.util.StringUtils; +import java.io.PrintWriter; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -47,23 +48,27 @@ import java.util.concurrent.Executor; * always have permissions for accessing the {@link android.provider.DeviceConfig}). */ public interface ConfigStore { + + // TODO(b/288066342): Remove and replace after new constant definition in + // {@link android.provider.DeviceConfig}. + String NAMESPACE_MEDIAPROVIDER = "mediaprovider"; boolean DEFAULT_TAKE_OVER_GET_CONTENT = false; boolean DEFAULT_USER_SELECT_FOR_APP = true; boolean DEFAULT_STABILISE_VOLUME_INTERNAL = false; boolean DEFAULT_STABILIZE_VOLUME_EXTERNAL = false; + boolean DEFAULT_STABILIZE_VOLUME_PUBLIC = false; boolean DEFAULT_TRANSCODE_ENABLED = true; boolean DEFAULT_TRANSCODE_OPT_OUT_STRATEGY_ENABLED = false; int DEFAULT_TRANSCODE_MAX_DURATION = 60 * 1000; // 1 minute - int DEFAULT_PICKER_SYNC_DELAY = 5000; // 5 seconds - boolean DEFAULT_PICKER_GET_CONTENT_PRELOAD = true; boolean DEFAULT_PICKER_PICK_IMAGES_PRELOAD = true; boolean DEFAULT_PICKER_PICK_IMAGES_RESPECT_PRELOAD_ARG = false; - boolean DEFAULT_CLOUD_MEDIA_IN_PHOTO_PICKER_ENABLED = false; + boolean DEFAULT_CLOUD_MEDIA_IN_PHOTO_PICKER_ENABLED = true; boolean DEFAULT_ENFORCE_CLOUD_PROVIDER_ALLOWLIST = true; + boolean DEFAULT_PICKER_CHOICE_MANAGED_SELECTION_ENABLED = true; /** * @return if the Cloud-Media-in-Photo-Picker enabled (e.g. platform will recognize and @@ -74,6 +79,14 @@ public interface ConfigStore { } /** + * @return if the Picker-Choice_Managed_selection is enabled. + */ + default boolean isPickerChoiceManagedSelectionEnabled() { + return DEFAULT_PICKER_CHOICE_MANAGED_SELECTION_ENABLED; + } + + + /** * @return package name of the pre-configured "system default" * {@link android.provider.CloudMediaProvider}. * @see #isCloudMediaInPhotoPickerEnabled() @@ -104,14 +117,6 @@ public interface ConfigStore { } /** - * @return a delay (in milliseconds) before executing PhotoPicker media sync on media events - * like inserts/updates/deletes to artificially throttle the burst notifications. - */ - default int getPickerSyncDelayMs() { - return DEFAULT_PICKER_SYNC_DELAY; - } - - /** * @return if {@link com.android.providers.media.photopicker.PhotoPickerActivity} should preload * selected media items before "returning" * ({@link com.android.providers.media.photopicker.PhotoPickerActivity#setResultAndFinishSelf()}) @@ -180,6 +185,13 @@ public interface ConfigStore { } /** + * @return if stable URI are enabled for public volumes. + */ + default boolean isStableUrisForPublicVolumeEnabled() { + return DEFAULT_STABILIZE_VOLUME_PUBLIC; + } + + /** * @return if transcoding is enabled. */ default boolean isTranscodeEnabled() { @@ -212,6 +224,33 @@ public interface ConfigStore { void addOnChangeListener(@NonNull Executor executor, @NonNull Runnable listener); /** + * Print the {@link ConfigStore} state into the given stream. + */ + default void dump(PrintWriter writer) { + writer.println("Config store state:"); + writer.println(" isCloudMediaInPhotoPickerEnabled=" + isCloudMediaInPhotoPickerEnabled()); + writer.println(" defaultCloudProviderPackage=" + getDefaultCloudProviderPackage()); + writer.println(" allowedCloudProviderPackages=" + getAllowedCloudProviderPackages()); + writer.println(" shouldEnforceCloudProviderAllowlist=" + + shouldEnforceCloudProviderAllowlist()); + writer.println(" shouldPickerPreloadForGetContent=" + shouldPickerPreloadForGetContent()); + writer.println(" shouldPickerPreloadForPickImages=" + shouldPickerPreloadForPickImages()); + writer.println(" shouldPickerRespectPreloadArgumentForPickImages=" + + shouldPickerRespectPreloadArgumentForPickImages()); + writer.println(" isGetContentTakeOverEnabled=" + isGetContentTakeOverEnabled()); + writer.println(" isUserSelectForAppEnabled=" + isUserSelectForAppEnabled()); + writer.println(" isStableUrisForInternalVolumeEnabled=" + + isStableUrisForInternalVolumeEnabled()); + writer.println(" isStableUrisForExternalVolumeEnabled=" + + isStableUrisForExternalVolumeEnabled()); + writer.println(" isTranscodeEnabled=" + isTranscodeEnabled()); + writer.println(" shouldTranscodeDefault=" + shouldTranscodeDefault()); + writer.println(" transcodeMaxDurationMs=" + getTranscodeMaxDurationMs()); + writer.println(" transcodeCompatManifest=" + getTranscodeCompatManifest()); + writer.println(" transcodeCompatStale=" + getTranscodeCompatStale()); + } + + /** * Implementation of the {@link ConfigStore} that reads "real" configs from * {@link android.provider.DeviceConfig}. Meant to be used by the "production" code. */ @@ -220,8 +259,11 @@ public interface ConfigStore { private static final String KEY_USER_SELECT_FOR_APP = "user_select_for_app"; @VisibleForTesting - public static final String KEY_STABILISE_VOLUME_INTERNAL = "stablise_volume_internal"; - private static final String KEY_STABILIZE_VOLUME_EXTERNAL = "stabilize_volume_external"; + public static final String KEY_STABILIZE_VOLUME_INTERNAL = "stabilize_volume_internal"; + @VisibleForTesting + public static final String KEY_STABILIZE_VOLUME_EXTERNAL = "stabilize_volume_external"; + @VisibleForTesting + public static final String KEY_STABILIZE_VOLUME_PUBLIC = "stabilize_volume_public"; private static final String KEY_TRANSCODE_ENABLED = "transcode_enabled"; private static final String KEY_TRANSCODE_OPT_OUT_STRATEGY_ENABLED = "transcode_default"; @@ -232,7 +274,7 @@ public interface ConfigStore { private static final String SYSPROP_TRANSCODE_MAX_DURATION = "persist.sys.fuse.transcode_max_file_duration_ms"; private static final int TRANSCODE_MAX_DURATION_INVALID = 0; - private static final String KEY_PICKER_SYNC_DELAY = "default_sync_delay_ms"; + private static final String KEY_PICKER_GET_CONTENT_PRELOAD = "picker_get_content_preload_selected"; private static final String KEY_PICKER_PICK_IMAGES_PRELOAD = @@ -241,6 +283,8 @@ public interface ConfigStore { "picker_pick_images_respect_preload_selected_arg"; private static final String KEY_CLOUD_MEDIA_FEATURE_ENABLED = "cloud_media_feature_enabled"; + private static final String KEY_PICKER_CHOICE_MANAGED_SELECTION_ENABLED = + "picker_choice_managed_selection_enabled"; private static final String KEY_CLOUD_MEDIA_PROVIDER_ALLOWLIST = "allowed_cloud_providers"; private static final String KEY_CLOUD_MEDIA_ENFORCE_PROVIDER_ALLOWLIST = "cloud_media_enforce_provider_allowlist"; @@ -256,8 +300,27 @@ public interface ConfigStore { @Override public boolean isCloudMediaInPhotoPickerEnabled() { - return getBooleanDeviceConfig(KEY_CLOUD_MEDIA_FEATURE_ENABLED, - DEFAULT_CLOUD_MEDIA_IN_PHOTO_PICKER_ENABLED); + Boolean isEnabled = + getBooleanDeviceConfig( + NAMESPACE_MEDIAPROVIDER, + KEY_CLOUD_MEDIA_FEATURE_ENABLED, + DEFAULT_CLOUD_MEDIA_IN_PHOTO_PICKER_ENABLED); + + List<String> allowList = + getStringArrayDeviceConfig( + NAMESPACE_MEDIAPROVIDER, KEY_CLOUD_MEDIA_PROVIDER_ALLOWLIST); + + // Only consider the feature enabled when the enabled flag is on AND when the allowlist + // of permitted cloud media providers is not empty. + return isEnabled && !allowList.isEmpty(); + } + + @Override + public boolean isPickerChoiceManagedSelectionEnabled() { + return getBooleanDeviceConfig( + NAMESPACE_MEDIAPROVIDER, + KEY_PICKER_CHOICE_MANAGED_SELECTION_ENABLED, + DEFAULT_PICKER_CHOICE_MANAGED_SELECTION_ENABLED); } @Nullable @@ -281,7 +344,8 @@ public interface ConfigStore { @Override public List<String> getAllowedCloudProviderPackages() { final List<String> allowlist = - getStringArrayDeviceConfig(KEY_CLOUD_MEDIA_PROVIDER_ALLOWLIST); + getStringArrayDeviceConfig(NAMESPACE_MEDIAPROVIDER, + KEY_CLOUD_MEDIA_PROVIDER_ALLOWLIST); // BACKWARD COMPATIBILITY WORKAROUND. // See javadoc to maybeExtractPackageNameFromCloudProviderAuthority() below for more @@ -299,16 +363,13 @@ public interface ConfigStore { @Override public boolean shouldEnforceCloudProviderAllowlist() { - return getBooleanDeviceConfig(KEY_CLOUD_MEDIA_ENFORCE_PROVIDER_ALLOWLIST, + return getBooleanDeviceConfig( + NAMESPACE_MEDIAPROVIDER, + KEY_CLOUD_MEDIA_ENFORCE_PROVIDER_ALLOWLIST, DEFAULT_ENFORCE_CLOUD_PROVIDER_ALLOWLIST); } @Override - public int getPickerSyncDelayMs() { - return getIntDeviceConfig(KEY_PICKER_SYNC_DELAY, DEFAULT_PICKER_SYNC_DELAY); - } - - @Override public boolean shouldPickerPreloadForGetContent() { return getBooleanDeviceConfig(KEY_PICKER_GET_CONTENT_PRELOAD, DEFAULT_PICKER_GET_CONTENT_PRELOAD); @@ -338,14 +399,20 @@ public interface ConfigStore { @Override public boolean isStableUrisForInternalVolumeEnabled() { - return getBooleanDeviceConfig( - KEY_STABILISE_VOLUME_INTERNAL, DEFAULT_STABILISE_VOLUME_INTERNAL); + return getBooleanDeviceConfig(NAMESPACE_MEDIAPROVIDER, KEY_STABILIZE_VOLUME_INTERNAL, + DEFAULT_STABILISE_VOLUME_INTERNAL); } @Override public boolean isStableUrisForExternalVolumeEnabled() { - return getBooleanDeviceConfig( - KEY_STABILIZE_VOLUME_EXTERNAL, DEFAULT_STABILIZE_VOLUME_EXTERNAL); + return getBooleanDeviceConfig(NAMESPACE_MEDIAPROVIDER, KEY_STABILIZE_VOLUME_EXTERNAL, + DEFAULT_STABILIZE_VOLUME_EXTERNAL); + } + + @Override + public boolean isStableUrisForPublicVolumeEnabled() { + return getBooleanDeviceConfig(NAMESPACE_MEDIAPROVIDER, KEY_STABILIZE_VOLUME_PUBLIC, + DEFAULT_STABILIZE_VOLUME_PUBLIC); } @Override @@ -400,6 +467,8 @@ public interface ConfigStore { // that make changes to this package independent of reboot DeviceConfig.addOnPropertiesChangedListener( NAMESPACE_STORAGE_NATIVE_BOOT, executor, unused -> listener.run()); + DeviceConfig.addOnPropertiesChangedListener( + NAMESPACE_MEDIAPROVIDER, executor, unused -> listener.run()); } private static boolean getBooleanDeviceConfig(@NonNull String key, boolean defaultValue) { @@ -410,6 +479,15 @@ public interface ConfigStore { DeviceConfig.getBoolean(NAMESPACE_STORAGE_NATIVE_BOOT, key, defaultValue)); } + private static boolean getBooleanDeviceConfig(@NonNull String namespace, + @NonNull String key, boolean defaultValue) { + if (!sCanReadDeviceConfig) { + return defaultValue; + } + return withCleanCallingIdentity( + () -> DeviceConfig.getBoolean(namespace, key, defaultValue)); + } + private static int getIntDeviceConfig(@NonNull String key, int defaultValue) { if (!sCanReadDeviceConfig) { return defaultValue; @@ -426,6 +504,15 @@ public interface ConfigStore { DeviceConfig.getString(NAMESPACE_STORAGE_NATIVE_BOOT, key, null)); } + private static String getStringDeviceConfig(@NonNull String namespace, + @NonNull String key) { + if (!sCanReadDeviceConfig) { + return null; + } + return withCleanCallingIdentity(() -> + DeviceConfig.getString(namespace, key, null)); + } + private static List<String> getStringArrayDeviceConfig(@NonNull String key) { final String items = getStringDeviceConfig(key); if (StringUtils.isNullOrEmpty(items)) { @@ -434,6 +521,15 @@ public interface ConfigStore { return Arrays.asList(items.split(",")); } + private static List<String> getStringArrayDeviceConfig(@NonNull String namespace, + @NonNull String key) { + final String items = getStringDeviceConfig(namespace, key); + if (StringUtils.isNullOrEmpty(items)) { + return Collections.emptyList(); + } + return Arrays.asList(items.split(",")); + } + private static <T> T withCleanCallingIdentity(@NonNull Supplier<T> action) { final long callingIdentity = Binder.clearCallingIdentity(); try { diff --git a/src/com/android/providers/media/DatabaseBackupAndRecovery.java b/src/com/android/providers/media/DatabaseBackupAndRecovery.java index cef55ed56..2a99e7d02 100644 --- a/src/com/android/providers/media/DatabaseBackupAndRecovery.java +++ b/src/com/android/providers/media/DatabaseBackupAndRecovery.java @@ -16,6 +16,11 @@ package com.android.providers.media; +import static com.android.providers.media.DatabaseHelper.DATA_MEDIA_XATTR_DIRECTORY_PATH; +import static com.android.providers.media.DatabaseHelper.EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX; +import static com.android.providers.media.DatabaseHelper.EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX; +import static com.android.providers.media.DatabaseHelper.INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX; +import static com.android.providers.media.DatabaseHelper.INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX; import static com.android.providers.media.MediaProviderStatsLog.MEDIA_PROVIDER_VOLUME_RECOVERY_REPORTED__VOLUME__EXTERNAL_PRIMARY; import static com.android.providers.media.MediaProviderStatsLog.MEDIA_PROVIDER_VOLUME_RECOVERY_REPORTED__VOLUME__INTERNAL; import static com.android.providers.media.MediaProviderStatsLog.MEDIA_PROVIDER_VOLUME_RECOVERY_REPORTED__VOLUME__PUBLIC; @@ -25,12 +30,15 @@ import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.os.CancellationSignal; +import android.os.Environment; import android.os.ParcelFileDescriptor; import android.os.SystemClock; import android.os.SystemProperties; import android.os.UserHandle; import android.provider.MediaStore; +import android.system.ErrnoException; import android.system.Os; +import android.system.OsConstants; import android.util.Log; import android.util.Pair; @@ -46,13 +54,17 @@ import com.google.common.base.Strings; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; /** * To ensure that the ids of MediaStore database uris are stable and reliable. @@ -70,12 +82,9 @@ public class DatabaseBackupAndRecovery { "/data/media/" + UserHandle.myUserId() + "/.transforms/recovery/leveldb-ownership"; /** - * Path which stores backup of external primary volume. - * Lower file system path is used as upper file system does not support xattrs. + * Every LevelDB table name starts with this prefix. */ - private static final String EXTERNAL_PRIMARY_VOLUME_BACKUP_PATH = - "/data/media/" + UserHandle.myUserId() - + "/.transforms/recovery/leveldb-external_primary"; + private static final String LEVEL_DB_PREFIX = "leveldb-"; /** * Frequency at which next value of owner id is backed up in the external storage. @@ -116,15 +125,11 @@ public class DatabaseBackupAndRecovery { MediaStore.Files.FileColumns._USER_ID, MediaStore.Files.FileColumns.DATE_EXPIRES, MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME, - MediaStore.Files.FileColumns.GENERATION_MODIFIED + MediaStore.Files.FileColumns.GENERATION_MODIFIED, + MediaStore.Files.FileColumns.VOLUME_NAME }; /** - * Wait time of 5 seconds in millis. - */ - private static final long WAIT_TIME_5_SECONDS_IN_MILLIS = 5000; - - /** * Wait time of 10 seconds in millis. */ private static final long WAIT_TIME_10_SECONDS_IN_MILLIS = 10000; @@ -146,10 +151,9 @@ public class DatabaseBackupAndRecovery { private AtomicInteger mNextOwnerIdBackup; private final ConfigStore mConfigStore; private final VolumeCache mVolumeCache; + private Set<String> mSetupCompletePublicVolumes = ConcurrentHashMap.newKeySet(); - private AtomicBoolean mIsBackupSetupComplete = new AtomicBoolean(false); - - private Map<String, String> mOwnerIdRelationMap; + private static Map<String, String> sOwnerIdRelationMap; protected DatabaseBackupAndRecovery(ConfigStore configStore, VolumeCache volumeCache) { mConfigStore = configStore; @@ -171,20 +175,11 @@ public class DatabaseBackupAndRecovery { "persist.sys.fuse.backup.external_volume_backup", /* defaultValue */ false); default: - return false; - } - } - - protected void onConfigPropertyChangeListener() { - if ((mConfigStore.isStableUrisForInternalVolumeEnabled() - || mConfigStore.isStableUrisForExternalVolumeEnabled()) - && mVolumeCache.getExternalVolumeNames().contains( - MediaStore.VOLUME_EXTERNAL_PRIMARY)) { - Log.i(TAG, - "On device config change, found stable uri support enabled. Attempting backup" - + " and recovery setup."); - setupVolumeDbBackupAndRecovery(MediaStore.VOLUME_EXTERNAL_PRIMARY, - new File(EXTERNAL_PRIMARY_ROOT_PATH)); + // public volume + return isStableUrisEnabled(MediaStore.VOLUME_EXTERNAL_PRIMARY) + && mConfigStore.isStableUrisForPublicVolumeEnabled() + || SystemProperties.getBoolean("persist.sys.fuse.backup.public_db_backup", + /* defaultValue */ false); } } @@ -195,10 +190,9 @@ public class DatabaseBackupAndRecovery { * volume on Media mount signal of EXTERNAL_PRIMARY. */ protected synchronized void setupVolumeDbBackupAndRecovery(String volumeName, File volumePath) { - // We are setting up leveldb instance only for internal volume as of now. Since internal - // volume does not have any fuse daemon thread, leveldb instance is created by fuse - // daemon thread of EXTERNAL_PRIMARY. - if (!MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName)) { + // Since internal volume does not have any fuse daemon thread, leveldb instance + // for internal volume is created by fuse daemon thread of EXTERNAL_PRIMARY. + if (MediaStore.VOLUME_INTERNAL.equalsIgnoreCase(volumeName)) { // Set backup only for external primary for now. return; } @@ -208,22 +202,37 @@ public class DatabaseBackupAndRecovery { return; } - if (mIsBackupSetupComplete.get()) { + if (mSetupCompletePublicVolumes.contains(volumeName)) { // Return if setup is already done return; } + final long startTime = SystemClock.elapsedRealtime(); try { if (!new File(RECOVERY_DIRECTORY_PATH).exists()) { new File(RECOVERY_DIRECTORY_PATH).mkdirs(); } - FuseDaemon fuseDaemon = getFuseDaemonForFileWithWait(volumePath, - WAIT_TIME_5_SECONDS_IN_MILLIS); - fuseDaemon.setupVolumeDbBackup(); - mIsBackupSetupComplete = new AtomicBoolean(true); + FuseDaemon fuseDaemon = getFuseDaemonForFileWithWait(new File( + DatabaseBackupAndRecovery.EXTERNAL_PRIMARY_ROOT_PATH)); + Log.d(TAG, "Received db backup Fuse Daemon for: " + volumeName); + if (isStableUrisEnabled(volumeName)) { + if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName)) { + // Setup internal and external volumes + fuseDaemon.setupVolumeDbBackup(); + } else { + // Setup public volume + fuseDaemon.setupPublicVolumeDbBackup(volumeName); + } + mSetupCompletePublicVolumes.add(volumeName); + } } catch (IOException e) { Log.e(TAG, "Failure in setting up backup and recovery for volume: " + volumeName, e); + return; + } finally { + Log.i(TAG, "Backup and recovery setup time taken in milliseconds:" + ( + SystemClock.elapsedRealtime() - startTime)); } + Log.i(TAG, "Successfully set up backup and recovery for volume: " + volumeName); } /** @@ -231,9 +240,19 @@ public class DatabaseBackupAndRecovery { */ public void backupDatabases(DatabaseHelper internalDatabaseHelper, DatabaseHelper externalDatabaseHelper, CancellationSignal signal) { + setupVolumeDbBackupAndRecovery(MediaStore.VOLUME_EXTERNAL_PRIMARY, + new File(EXTERNAL_PRIMARY_ROOT_PATH)); Log.i(TAG, "Triggering database backup"); backupInternalDatabase(internalDatabaseHelper, signal); - backupExternalDatabase(externalDatabaseHelper, signal); + backupExternalDatabase(externalDatabaseHelper, MediaStore.VOLUME_EXTERNAL_PRIMARY, signal); + + for (MediaVolume mediaVolume : mVolumeCache.getExternalVolumes()) { + if (mediaVolume.isPublicVolume()) { + setupVolumeDbBackupAndRecovery(mediaVolume.getName(), + new File(EXTERNAL_PRIMARY_ROOT_PATH)); + backupExternalDatabase(externalDatabaseHelper, mediaVolume.getName(), signal); + } + } } protected Optional<BackupIdRow> readDataFromBackup(String volumeName, String filePath) { @@ -241,9 +260,14 @@ public class DatabaseBackupAndRecovery { return Optional.empty(); } - final String fuseDaemonFilePath = getFuseDaemonFilePath(filePath); try { - final String data = getFuseDaemonForPath(fuseDaemonFilePath).readBackedUpData(filePath); + final String data = getFuseDaemonForPath(EXTERNAL_PRIMARY_ROOT_PATH) + .readBackedUpData(filePath); + if (data == null || data.isEmpty()) { + Log.w(TAG, "No backup found for path: " + filePath); + return Optional.empty(); + } + return Optional.of(BackupIdRow.deserialize(data)); } catch (Exception e) { Log.e(TAG, "Failure in getting backed up data for filePath: " + filePath, e); @@ -251,16 +275,15 @@ public class DatabaseBackupAndRecovery { } } - protected void backupInternalDatabase(DatabaseHelper internalDbHelper, + protected synchronized void backupInternalDatabase(DatabaseHelper internalDbHelper, CancellationSignal signal) { if (!isStableUrisEnabled(MediaStore.VOLUME_INTERNAL) || internalDbHelper.isDatabaseRecovering()) { return; } - if (!mIsBackupSetupComplete.get()) { - setupVolumeDbBackupAndRecovery(MediaStore.VOLUME_EXTERNAL, - new File(EXTERNAL_PRIMARY_ROOT_PATH)); + if (!mSetupCompletePublicVolumes.contains(MediaStore.VOLUME_EXTERNAL_PRIMARY)) { + return; } FuseDaemon fuseDaemon; @@ -291,63 +314,60 @@ public class DatabaseBackupAndRecovery { }); } - protected void backupExternalDatabase(DatabaseHelper externalDbHelper, - CancellationSignal signal) { - if (!isStableUrisEnabled(MediaStore.VOLUME_EXTERNAL_PRIMARY) + protected synchronized void backupExternalDatabase(DatabaseHelper externalDbHelper, + String volumeName, CancellationSignal signal) { + if (!isStableUrisEnabled(volumeName) || externalDbHelper.isDatabaseRecovering()) { return; } - if (!mIsBackupSetupComplete.get()) { - setupVolumeDbBackupAndRecovery(MediaStore.VOLUME_EXTERNAL, - new File(EXTERNAL_PRIMARY_ROOT_PATH)); + if (!mSetupCompletePublicVolumes.contains(volumeName)) { + return; } FuseDaemon fuseDaemon; try { - fuseDaemon = getFuseDaemonForFileWithWait(new File(EXTERNAL_PRIMARY_ROOT_PATH), - WAIT_TIME_5_SECONDS_IN_MILLIS); + fuseDaemon = getFuseDaemonForFileWithWait(new File(EXTERNAL_PRIMARY_ROOT_PATH)); } catch (FileNotFoundException e) { Log.e(TAG, "Fuse Daemon not found for primary external storage, skipping backing up of " - + "external database.", + + volumeName, e); return; } - // Read last backed up generation number - Optional<Long> lastBackedUpGenNum = getXattrOfLongValue( - EXTERNAL_PRIMARY_VOLUME_BACKUP_PATH, LAST_BACKEDUP_GENERATION_XATTR_KEY); - long lastBackedGenerationNumber = lastBackedUpGenNum.isPresent() - ? lastBackedUpGenNum.get() : 0; - if (lastBackedGenerationNumber > 0) { - Log.i(TAG, "Last backed up generation number is " + lastBackedGenerationNumber); - } - final String generationClause = MediaStore.Files.FileColumns.GENERATION_MODIFIED + " > " + final String backupPath = RECOVERY_DIRECTORY_PATH + "/" + LEVEL_DB_PREFIX + volumeName; + long lastBackedGenerationNumber = getLastBackedGenerationNumber(backupPath); + + final String generationClause = MediaStore.Files.FileColumns.GENERATION_MODIFIED + " >= " + lastBackedGenerationNumber; final String volumeClause = MediaStore.Files.FileColumns.VOLUME_NAME + " = '" - + MediaStore.VOLUME_EXTERNAL_PRIMARY + "'"; + + volumeName + "'"; final String selectionClause = generationClause + " AND " + volumeClause; externalDbHelper.runWithTransaction((db) -> { long maxGeneration = lastBackedGenerationNumber; + Log.d(TAG, "Started to back up " + volumeName + + ", maxGeneration:" + maxGeneration); try (Cursor c = db.query(true, "files", QUERY_COLUMNS, selectionClause, null, null, - null, null, null, signal)) { + null, MediaStore.MediaColumns._ID + " ASC", null, signal)) { while (c.moveToNext()) { if (signal != null && signal.isCanceled()) { + Log.i(TAG, "Received a cancellation signal during the DB " + + "backup process"); break; } backupDataValues(fuseDaemon, c); maxGeneration = Math.max(maxGeneration, c.getLong(9)); } - setXattr(EXTERNAL_PRIMARY_VOLUME_BACKUP_PATH, LAST_BACKEDUP_GENERATION_XATTR_KEY, - String.valueOf(maxGeneration)); + setXattr(backupPath, LAST_BACKEDUP_GENERATION_XATTR_KEY, + String.valueOf(maxGeneration - 1)); Log.d(TAG, String.format(Locale.ROOT, - "Backed up %d rows of external database to external storage on idle " + "Backed up %d rows of " + volumeName + " to external storage on idle " + "maintenance.", c.getCount())); } catch (Exception e) { - Log.e(TAG, "Failure in backing up external database to external storage.", e); + Log.e(TAG, "Failure in backing up " + volumeName + " to external storage.", e); return null; } return null; @@ -364,10 +384,11 @@ public class DatabaseBackupAndRecovery { final int userId = c.getInt(6); final String dateExpires = c.getString(7); final String ownerPackageName = c.getString(8); + final String volumeName = c.getString(10); BackupIdRow backupIdRow = createBackupIdRow(fuseDaemon, id, mediaType, isFavorite, isPending, isTrashed, userId, dateExpires, ownerPackageName); - fuseDaemon.backupVolumeDbData(data, BackupIdRow.serialize(backupIdRow)); + fuseDaemon.backupVolumeDbData(volumeName, data, BackupIdRow.serialize(backupIdRow)); } protected void deleteBackupForVolume(String volumeName) { @@ -414,6 +435,19 @@ public class DatabaseBackupAndRecovery { } } + private long getLastBackedGenerationNumber(String backupPath) { + // Read last backed up generation number + Optional<Long> lastBackedUpGenNum = getXattrOfLongValue( + backupPath, LAST_BACKEDUP_GENERATION_XATTR_KEY); + long lastBackedGenerationNumber = lastBackedUpGenNum.isPresent() + ? lastBackedUpGenNum.get() : 0; + if (lastBackedGenerationNumber > 0) { + Log.i(TAG, "Last backed up generation number for " + backupPath + " is " + + lastBackedGenerationNumber); + } + return lastBackedGenerationNumber; + } + @NonNull private FuseDaemon getFuseDaemonForPath(@NonNull String path) throws FileNotFoundException { @@ -434,21 +468,16 @@ public class DatabaseBackupAndRecovery { return; } - // For all internal file paths, redirect to external primary fuse daemon. - final String fuseDaemonFilePath = getFuseDaemonFilePath(insertedRow.getPath()); try { - FuseDaemon fuseDaemon = getFuseDaemonForPath(fuseDaemonFilePath); + FuseDaemon fuseDaemon = getFuseDaemonForPath(EXTERNAL_PRIMARY_ROOT_PATH); final BackupIdRow value = createBackupIdRow(fuseDaemon, insertedRow); - fuseDaemon.backupVolumeDbData(insertedRow.getPath(), BackupIdRow.serialize(value)); + fuseDaemon.backupVolumeDbData(insertedRow.getVolumeName(), insertedRow.getPath(), + BackupIdRow.serialize(value)); } catch (Exception e) { Log.e(TAG, "Failure in backing up data to external storage", e); } } - private String getFuseDaemonFilePath(String filePath) { - return filePath.startsWith("/storage") ? filePath : EXTERNAL_PRIMARY_ROOT_PATH; - } - private BackupIdRow createBackupIdRow(FuseDaemon fuseDaemon, FileRow insertedRow) throws IOException { return createBackupIdRow(fuseDaemon, insertedRow.getId(), insertedRow.getMediaType(), @@ -503,7 +532,7 @@ public class DatabaseBackupAndRecovery { int nextOwnerId = getAndIncrementNextOwnerId(); fuseDaemon.createOwnerIdRelation(String.valueOf(nextOwnerId), ownerPackageIdentifier); - Log.i(TAG, "Created relation b/w " + nextOwnerId + " and " + ownerPackageIdentifier); + Log.v(TAG, "Created relation b/w " + nextOwnerId + " and " + ownerPackageIdentifier); return nextOwnerId; } @@ -576,10 +605,9 @@ public class DatabaseBackupAndRecovery { return; } - // For all internal file paths, redirect to external primary fuse daemon. - String fuseDaemonFilePath = getFuseDaemonFilePath(deletedFilePath); try { - getFuseDaemonForPath(fuseDaemonFilePath).deleteDbBackup(deletedFilePath); + getFuseDaemonForPath(EXTERNAL_PRIMARY_ROOT_PATH).deleteDbBackup( + deletedFilePath); } catch (IOException e) { Log.w(TAG, "Failure in deleting backup data for key: " + deletedFilePath, e); } @@ -588,7 +616,7 @@ public class DatabaseBackupAndRecovery { protected boolean isBackupUpdateAllowed(DatabaseHelper databaseHelper, String volumeName) { // Backup only if stable uris is enabled, db is not recovering and backup setup is complete. return isStableUrisEnabled(volumeName) && !databaseHelper.isDatabaseRecovering() - && mIsBackupSetupComplete.get(); + && mSetupCompletePublicVolumes.contains(volumeName); } @@ -614,10 +642,10 @@ public class DatabaseBackupAndRecovery { } final String updatedFilePath = updatedRow.getPath(); - // For all internal file paths, redirect to external primary fuse daemon. - final String fuseDaemonFilePath = getFuseDaemonFilePath(updatedFilePath); try { - getFuseDaemonForPath(fuseDaemonFilePath).backupVolumeDbData(updatedFilePath, + getFuseDaemonForPath(EXTERNAL_PRIMARY_ROOT_PATH).backupVolumeDbData( + updatedRow.getVolumeName(), + updatedFilePath, BackupIdRow.serialize(BackupIdRow.newBuilder(updatedRow.getId()).setIsDirty( true).build())); } catch (IOException e) { @@ -629,7 +657,7 @@ public class DatabaseBackupAndRecovery { /** * Reads value corresponding to given key from xattr on given path. */ - public static Optional<String> getXattr(String path, String key) { + static Optional<String> getXattr(String path, String key) { try { return Optional.of(Arrays.toString(Os.getxattr(path, key))); } catch (Exception e) { @@ -642,7 +670,7 @@ public class DatabaseBackupAndRecovery { /** * Reads long value corresponding to given key from xattr on given path. */ - public static Optional<Long> getXattrOfLongValue(String path, String key) { + static Optional<Long> getXattrOfLongValue(String path, String key) { try { return Optional.of(Long.parseLong(new String(Os.getxattr(path, key)))); } catch (Exception e) { @@ -655,7 +683,7 @@ public class DatabaseBackupAndRecovery { /** * Reads integer value corresponding to given key from xattr on given path. */ - public static Optional<Integer> getXattrOfIntegerValue(String path, String key) { + static Optional<Integer> getXattrOfIntegerValue(String path, String key) { try { return Optional.of(Integer.parseInt(new String(Os.getxattr(path, key)))); } catch (Exception e) { @@ -668,7 +696,7 @@ public class DatabaseBackupAndRecovery { /** * Sets key and value as xattr on given path. */ - public static boolean setXattr(String path, String key, String value) { + static boolean setXattr(String path, String key, String value) { try (ParcelFileDescriptor pfd = ParcelFileDescriptor.open(new File(path), ParcelFileDescriptor.MODE_READ_ONLY)) { // Map id value to xattr key @@ -683,6 +711,44 @@ public class DatabaseBackupAndRecovery { } } + /** + * Deletes xattr with given key on given path. Becomes a no-op when xattr is not present. + */ + static boolean removeXattr(String path, String key) { + try (ParcelFileDescriptor pfd = ParcelFileDescriptor.open(new File(path), + ParcelFileDescriptor.MODE_READ_ONLY)) { + Os.removexattr(path, key); + Os.fsync(pfd.getFileDescriptor()); + Log.d(TAG, String.format("xattr key:%s removed on path: %s.", key, path)); + return true; + } catch (Exception e) { + if (e instanceof ErrnoException) { + ErrnoException exception = (ErrnoException) e; + if (exception.errno == OsConstants.ENODATA) { + Log.w(TAG, String.format(Locale.ROOT, + "xattr:%s is not removed as it is not found on path: %s.", key, path)); + return true; + } + } + + Log.e(TAG, String.format(Locale.ROOT, "Failed to remove xattr:%s for path: %s.", key, + path), e); + return false; + } + } + + /** + * Lists xattrs of given path. + */ + static List<String> listXattr(String path) { + try { + return Arrays.asList(Os.listxattr(path)); + } catch (Exception e) { + Log.e(TAG, "Exception in reading xattrs on path: " + path, e); + return new ArrayList<>(); + } + } + protected void insertDataInDatabase(SQLiteDatabase db, BackupIdRow row, String filePath, String volumeName) { final ContentValues values = createValuesFromFileRow(row, filePath, volumeName); @@ -722,41 +788,60 @@ public class DatabaseBackupAndRecovery { return values; } - private Pair<String, Integer> getOwnerPackageNameAndUidPair(int ownerPackageId) { - if (mOwnerIdRelationMap == null) { + protected Pair<String, Integer> getOwnerPackageNameAndUidPair(int ownerPackageId) { + if (sOwnerIdRelationMap == null) { try { - mOwnerIdRelationMap = getFuseDaemonForPath( - EXTERNAL_PRIMARY_ROOT_PATH).readOwnerIdRelations(); - Log.i(TAG, "Cached owner id map"); + sOwnerIdRelationMap = readOwnerIdRelationsFromLevelDb(); + Log.v(TAG, "Cached owner id map"); } catch (IOException e) { Log.e(TAG, "Failure in reading owner details for owner id:" + ownerPackageId, e); return Pair.create(null, null); } } - if (mOwnerIdRelationMap.containsKey(String.valueOf(ownerPackageId))) { - return getPackageNameAndUserId(mOwnerIdRelationMap.get(String.valueOf(ownerPackageId))); + if (sOwnerIdRelationMap.containsKey(String.valueOf(ownerPackageId))) { + return getPackageNameAndUserId(sOwnerIdRelationMap.get(String.valueOf(ownerPackageId))); } + return Pair.create(null, null); } + protected Map<String, String> readOwnerIdRelationsFromLevelDb() throws IOException { + return getFuseDaemonForPath(EXTERNAL_PRIMARY_ROOT_PATH).readOwnerIdRelations(); + } + + protected String readOwnerPackageName(String ownerId) throws IOException { + Map<String, String> ownerIdRelationMap = readOwnerIdRelationsFromLevelDb(); + if (ownerIdRelationMap.containsKey(String.valueOf(ownerId))) { + return getPackageNameAndUserId(ownerIdRelationMap.get(ownerId)).first; + } + + return null; + } + protected void recoverData(SQLiteDatabase db, String volumeName) { - if (!isBackupPresent()) { + if (!MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName) + && !MediaStore.VOLUME_INTERNAL.equalsIgnoreCase(volumeName)) { + // todo: implement for public volume return; } - final long startTime = SystemClock.elapsedRealtime(); final String fuseFilePath = getFuseFilePathFromVolumeName(volumeName); // Wait for external primary to be attached as we use same thread for internal volume. // Maximum wait for 10s try { - getFuseDaemonForFileWithWait(new File(fuseFilePath), WAIT_TIME_10_SECONDS_IN_MILLIS); + getFuseDaemonForFileWithWait(new File(fuseFilePath)); } catch (FileNotFoundException e) { Log.e(TAG, "Could not recover data as fuse daemon could not serve requests.", e); return; } - setupVolumeDbBackupAndRecovery(volumeName, new File(EXTERNAL_PRIMARY_ROOT_PATH)); + if (!isBackupPresent()) { + Log.w(TAG, "Backup is not present for " + volumeName); + return; + } + Log.d(TAG, "Backup is present for " + volumeName); + long rowsRecovered = 0; long dirtyRowsCount = 0; String[] backedUpFilePaths; @@ -765,10 +850,12 @@ public class DatabaseBackupAndRecovery { while (true) { backedUpFilePaths = readBackedUpFilePaths(volumeName, lastReadValue, LEVEL_DB_READ_LIMIT); - if (backedUpFilePaths.length <= 0) { + if (backedUpFilePaths.length == 0) { break; } + // Reset cached owner id relation map + sOwnerIdRelationMap = null; for (String filePath : backedUpFilePaths) { Optional<BackupIdRow> fileRow = readDataFromBackup(volumeName, filePath); if (fileRow.isPresent()) { @@ -795,8 +882,9 @@ public class DatabaseBackupAndRecovery { volumeName)); if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName)) { // Resetting generation number - setXattr(EXTERNAL_PRIMARY_VOLUME_BACKUP_PATH, LAST_BACKEDUP_GENERATION_XATTR_KEY, - String.valueOf(0)); + setXattr(RECOVERY_DIRECTORY_PATH + "/" + LEVEL_DB_PREFIX + + MediaStore.VOLUME_EXTERNAL_PRIMARY, + LAST_BACKEDUP_GENERATION_XATTR_KEY, String.valueOf(0)); } Log.i(TAG, String.format(Locale.ROOT, "Recovery time: %d ms", recoveryTime)); } @@ -805,9 +893,11 @@ public class DatabaseBackupAndRecovery { return new File(RECOVERY_DIRECTORY_PATH).exists(); } - protected FuseDaemon getFuseDaemonForFileWithWait(File fuseFilePath, long waitTime) + protected FuseDaemon getFuseDaemonForFileWithWait(File fuseFilePath) throws FileNotFoundException { - return MediaProvider.getFuseDaemonForFileWithWait(fuseFilePath, mVolumeCache, waitTime); + pollForExternalStorageMountedState(); + return MediaProvider.getFuseDaemonForFileWithWait(fuseFilePath, mVolumeCache, + WAIT_TIME_10_SECONDS_IN_MILLIS); } private int getVolumeNameForStatsLog(String volumeName) { @@ -863,12 +953,11 @@ public class DatabaseBackupAndRecovery { null, null)) { if (c.moveToFirst()) { backupDataValues(fuseDaemon, c); - Log.v(TAG, "Updated backed up row in leveldb"); String newPath = c.getString(1); if (oldRow.getPath() != null && !oldRow.getPath().equalsIgnoreCase(newPath)) { // If file path has changed, update leveldb backup to delete old path. deleteFromDbBackup(helper, oldRow); - Log.v(TAG, "Deleted backup of old file path."); + Log.v(TAG, "Deleted backup of old file path: " + oldRow.getPath()); } } } catch (Exception e) { @@ -877,4 +966,80 @@ public class DatabaseBackupAndRecovery { return null; }); } + + /** + * Removes database recovery data for given user id. This is done when a user is removed. + */ + protected void removeRecoveryDataForUserId(int removedUserId) { + String removeduserIdString = String.valueOf(removedUserId); + removeXattr(DATA_MEDIA_XATTR_DIRECTORY_PATH, + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.concat( + removeduserIdString)); + removeXattr(DATA_MEDIA_XATTR_DIRECTORY_PATH, + EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.concat( + removeduserIdString)); + removeXattr(DATA_MEDIA_XATTR_DIRECTORY_PATH, + INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.concat(removeduserIdString)); + removeXattr(DATA_MEDIA_XATTR_DIRECTORY_PATH, + EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.concat(removeduserIdString)); + Log.v(TAG, "Removed recovery data for user id: " + removedUserId); + } + + /** + * Removes database recovery data for obsolete user id. It accepts list of valid/active users + * and removes the recovery data for ones not present in this list. + * This is done during an idle maintenance. + */ + protected void removeRecoveryDataExceptValidUsers(List<String> validUsers) { + List<String> xattrList = listXattr(DATA_MEDIA_XATTR_DIRECTORY_PATH); + Log.i(TAG, "Xattr list is " + xattrList); + if (xattrList.isEmpty()) { + return; + } + + Log.i(TAG, "Valid users list is " + validUsers); + List<String> invalidUsers = getInvalidUsersList(xattrList, validUsers); + Log.i(TAG, "Invalid users list is " + invalidUsers); + for (String userIdToBeRemoved : invalidUsers) { + if (userIdToBeRemoved != null && !userIdToBeRemoved.trim().isEmpty()) { + removeRecoveryDataForUserId(Integer.parseInt(userIdToBeRemoved)); + } + } + } + + protected static List<String> getInvalidUsersList(List<String> recoveryData, + List<String> validUsers) { + Set<String> presentUserIdsAsXattr = new HashSet<>(); + for (String xattr : recoveryData) { + if (xattr.startsWith(INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX)) { + presentUserIdsAsXattr.add( + xattr.substring(INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.length())); + } else if (xattr.startsWith(EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX)) { + presentUserIdsAsXattr.add( + xattr.substring(EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.length())); + } else if (xattr.startsWith(INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX)) { + presentUserIdsAsXattr.add( + xattr.substring(INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.length())); + } else if (xattr.startsWith(EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX)) { + presentUserIdsAsXattr.add( + xattr.substring(EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.length())); + } + } + // Remove valid users + validUsers.forEach(presentUserIdsAsXattr::remove); + return presentUserIdsAsXattr.stream().collect(Collectors.toList()); + } + + private static void pollForExternalStorageMountedState() { + final File target = Environment.getExternalStorageDirectory(); + for (int i = 0; i < WAIT_TIME_10_SECONDS_IN_MILLIS / 100; i++) { + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState(target))) { + return; + } + Log.v(TAG, "Waiting for external storage..."); + SystemClock.sleep(100); + } + throw new RuntimeException("Timed out while waiting for ExternalStorageState " + + "to be MEDIA_MOUNTED"); + } } diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java index 69e54b2ae..0fb7ff3b3 100644 --- a/src/com/android/providers/media/DatabaseHelper.java +++ b/src/com/android/providers/media/DatabaseHelper.java @@ -112,28 +112,52 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { public static final String TEST_CLEAN_DB = "test_clean"; /** + * Prefix of key name of xattr used to set next row id for internal DB. + */ + static final String INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX = "user.intdbnextrowid"; + + /** * Key name of xattr used to set next row id for internal DB. */ - private static final String INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY = "user.intdbnextrowid".concat( - String.valueOf(UserHandle.myUserId())); + static final String INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY = + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.concat( + String.valueOf(UserHandle.myUserId())); + + /** + * Prefix of key name of xattr used to set next row id for external DB. + */ + static final String EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX = "user.extdbnextrowid"; /** * Key name of xattr used to set next row id for external DB. */ - private static final String EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY = "user.extdbnextrowid".concat( - String.valueOf(UserHandle.myUserId())); + static final String EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY = + EXTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX.concat( + String.valueOf(UserHandle.myUserId())); + + /** + * Prefix of key name of xattr used to set session id for internal DB. + */ + static final String INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX = "user.intdbsessionid"; /** * Key name of xattr used to set session id for internal DB. */ - private static final String INTERNAL_DB_SESSION_ID_XATTR_KEY = "user.intdbsessionid".concat( - String.valueOf(UserHandle.myUserId())); + static final String INTERNAL_DB_SESSION_ID_XATTR_KEY = + INTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.concat( + String.valueOf(UserHandle.myUserId())); + + /** + * Prefix of key name of xattr used to set session id for external DB. + */ + static final String EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX = "user.extdbsessionid"; /** * Key name of xattr used to set session id for external DB. */ - private static final String EXTERNAL_DB_SESSION_ID_XATTR_KEY = "user.extdbsessionid".concat( - String.valueOf(UserHandle.myUserId())); + static final String EXTERNAL_DB_SESSION_ID_XATTR_KEY = + EXTERNAL_DB_SESSION_ID_XATTR_KEY_PREFIX.concat( + String.valueOf(UserHandle.myUserId())); /** Indicates a billion value used when next row id is not present in respective xattr. */ private static final Long NEXT_ROW_ID_DEFAULT_BILLION_VALUE = Double.valueOf( @@ -148,7 +172,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { * For devices with adoptable storage support, opting for adoptable storage will not delete * /data/media/0 directory. */ - private static final String DATA_MEDIA_XATTR_DIRECTORY_PATH = "/data/media/0"; + static final String DATA_MEDIA_XATTR_DIRECTORY_PATH = "/data/media/0"; static final String INTERNAL_DATABASE_NAME = "internal.db"; static final String EXTERNAL_DATABASE_NAME = "external.db"; @@ -314,8 +338,8 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { // Recreate all views to apply this filter final SQLiteDatabase db = super.getWritableDatabase(); mSchemaLock.writeLock().lock(); + db.beginTransaction(); try { - db.beginTransaction(); createLatestViews(db); db.setTransactionSuccessful(); } finally { @@ -562,13 +586,6 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { getExternalStorageDbXattrPath(), getSessionIdXattrKeyForDatabase()); if (!lastUsedSessionIdFromExternalStoragePathXattr.isPresent()) { // First time scenario will have no session id at /data/media/0. - // Trigger database backup to external storage because - // StableUrisIdleMaintenanceService will be attempted to run only once in 7days. - // Any rollback before that will not recover DB rows. - if (isInternal()) { - BackgroundThread.getExecutor().execute( - () -> mDatabaseBackupAndRecovery.backupInternalDatabase(this, null)); - } // Set next row id in External Storage to handle rollback in future. backupNextRowId(NEXT_ROW_ID_DEFAULT_BILLION_VALUE); updateSessionIdInDatabaseAndExternalStorage(db); @@ -592,10 +609,15 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { // Recover data from backup // Ensure we do not back up in case of recovery. mIsRecovering.set(true); - mDatabaseBackupAndRecovery.recoverData(db, volumeName); - updateNextRowIdInDatabaseAndExternalStorage(db); - mIsRecovering.set(false); - updateSessionIdInDatabaseAndExternalStorage(db); + try { + mDatabaseBackupAndRecovery.recoverData(db, volumeName); + } catch (Exception exception) { + Log.e(TAG, "Error in recovering data", exception); + } finally { + updateNextRowIdInDatabaseAndExternalStorage(db); + mIsRecovering.set(false); + updateSessionIdInDatabaseAndExternalStorage(db); + } } } @@ -644,6 +666,10 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { "%s database inconsistent: isLastUsedDatabaseSession:%b, " + "nextRowIdOptionalPresent:%b", mName, isLastUsedDatabaseSession, nextRowIdFromXattrOptional.isPresent())); + + // This could be a rollback, clear all media grants + clearMediaGrantsTable(db); + // TODO(b/222313219): Add an assert to ensure that next row id xattr is always // present when DB session id matches across sequential open calls. updateNextRowIdInDatabaseAndExternalStorage(db); @@ -651,6 +677,15 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { } } + private void clearMediaGrantsTable(SQLiteDatabase db) { + mSchemaLock.writeLock().lock(); + try { + updateAddMediaGrantsTable(db); + } finally { + mSchemaLock.writeLock().unlock(); + } + } + @GuardedBy("sRecoveryLock") private boolean isLastUsedDatabaseSession(SQLiteDatabase db) { Optional<String> lastUsedSessionIdFromDatabasePathXattr = getXattr(db.getPath(), diff --git a/src/com/android/providers/media/LocalUriMatcher.java b/src/com/android/providers/media/LocalUriMatcher.java index 6a9174fd4..888a61969 100644 --- a/src/com/android/providers/media/LocalUriMatcher.java +++ b/src/com/android/providers/media/LocalUriMatcher.java @@ -78,6 +78,8 @@ class LocalUriMatcher { static final int PICKER_INTERNAL_ALBUMS_ALL = 904; static final int PICKER_INTERNAL_ALBUMS_LOCAL = 905; + public static final int MEDIA_GRANTS = 1000; + // MediaProvider Command Line Interface static final int CLI = 100_000; @@ -169,6 +171,7 @@ class LocalUriMatcher { mHidden.addURI(auth, "picker_internal/media/local", PICKER_INTERNAL_MEDIA_LOCAL); mHidden.addURI(auth, "picker_internal/albums/all", PICKER_INTERNAL_ALBUMS_ALL); mHidden.addURI(auth, "picker_internal/albums/local", PICKER_INTERNAL_ALBUMS_LOCAL); + mHidden.addURI(auth, "media_grants", MEDIA_GRANTS); mHidden.addURI(auth, "*", VOLUMES_ID); mHidden.addURI(auth, null, VOLUMES); diff --git a/src/com/android/providers/media/MediaGrants.java b/src/com/android/providers/media/MediaGrants.java index b08bb63d8..d654ca03b 100644 --- a/src/com/android/providers/media/MediaGrants.java +++ b/src/com/android/providers/media/MediaGrants.java @@ -16,10 +16,16 @@ package com.android.providers.media; +import static android.provider.MediaStore.MediaColumns.DATA; + import static com.android.providers.media.LocalUriMatcher.PICKER_ID; +import static com.android.providers.media.util.DatabaseUtils.replaceMatchAnyChar; import android.content.ContentUris; import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.provider.MediaStore; @@ -30,8 +36,11 @@ import androidx.annotation.NonNull; import com.android.providers.media.photopicker.PickerSyncController; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; /** * Manager class for the {@code media_grants} table in the {@link @@ -39,7 +48,7 @@ import java.util.Objects; * * <p>Manages media grants for files in the {@code files} table based on package name. */ -class MediaGrants { +public class MediaGrants { public static final String TAG = "MediaGrants"; public static final String MEDIA_GRANTS_TABLE = "media_grants"; public static final String FILE_ID_COLUMN = "file_id"; @@ -47,6 +56,40 @@ class MediaGrants { public static final String OWNER_PACKAGE_NAME_COLUMN = MediaStore.MediaColumns.OWNER_PACKAGE_NAME; + private static final String CREATE_TEMPORARY_TABLE_QUERY = "CREATE TEMPORARY TABLE "; + private static final String MEDIA_GRANTS_AND_FILES_JOIN_TABLE_NAME = "media_grants LEFT JOIN " + + "files ON media_grants.file_id = files._id"; + + private static final String WHERE_MEDIA_GRANTS_PACKAGE_NAME_IN = + "media_grants." + MediaGrants.OWNER_PACKAGE_NAME_COLUMN + " IN "; + + private static final String WHERE_MEDIA_GRANTS_USER_ID = + "media_grants." + MediaGrants.PACKAGE_USER_ID_COLUMN + " = ? "; + + private static final String WHERE_ITEM_IS_NOT_TRASHED = + "files." + MediaStore.Files.FileColumns.IS_TRASHED + " = ? "; + + private static final String WHERE_ITEM_IS_NOT_PENDING = + "files." + MediaStore.Files.FileColumns.IS_PENDING + " = ? "; + + private static final String WHERE_MEDIA_TYPE = + "files." + MediaStore.Files.FileColumns.MEDIA_TYPE + " IN "; + + private static final String WHERE_MIME_TYPE = + "files." + MediaStore.Files.FileColumns.MIME_TYPE + " LIKE ? "; + + private static final String WHERE_VOLUME_NAME_IN = + "files." + MediaStore.Files.FileColumns.VOLUME_NAME + " IN "; + + private static final String TEMP_TABLE_NAME_FOR_DELETION = + "temp_table_for_media_grants_deletion"; + + private static final String TEMP_TABLE_FOR_DELETION_FILE_ID_COLUMN_NAME = + "temp_table_for_media_grants_deletion.file_id"; + + private static final String ARG_VALUE_FOR_FALSE = "0"; + + private static final int VISUAL_MEDIA_TYPE_COUNT = 2; private SQLiteQueryBuilder mQueryBuilder = new SQLiteQueryBuilder(); private DatabaseHelper mExternalDatabase; private LocalUriMatcher mUriMatcher; @@ -87,7 +130,15 @@ class MediaGrants { values.put(FILE_ID_COLUMN, id); values.put(PACKAGE_USER_ID_COLUMN, packageUserId); - mQueryBuilder.insert(db, values); + try { + mQueryBuilder.insert(db, values); + } catch (SQLiteConstraintException exception) { + // no-op + // this may happen due to the presence of a foreign key between the + // media_grants and files table. An SQLiteConstraintException + // exception my occur if: while inserting the grant for a file, the + // file itself is deleted. In this situation no operation is required. + } } Log.d( @@ -101,6 +152,119 @@ class MediaGrants { } /** + * Returns the cursor for file data of items for which the passed package has READ_GRANTS. + * + * @param packageNames the package name that has access. + * @param packageUserId the user_id of the package + */ + Cursor getMediaGrantsForPackages(String[] packageNames, int packageUserId, + String[] mimeTypes, String[] availableVolumes) + throws IllegalArgumentException { + Objects.requireNonNull(packageNames); + return mExternalDatabase.runWithoutTransaction((db) -> { + final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setDistinct(true); + queryBuilder.setTables(MEDIA_GRANTS_AND_FILES_JOIN_TABLE_NAME); + String[] selectionArgs = buildSelectionArg(queryBuilder, + QueryFilterBuilder.newInstance() + .setPackageNameSelection(packageNames) + .setUserIdSelection(packageUserId) + .setIsNotTrashedSelection(true) + .setIsNotPendingSelection(true) + .setIsOnlyVisualMediaType(true) + .setMimeTypeSelection(mimeTypes) + .setAvailableVolumes(availableVolumes) + .build()); + + return queryBuilder.query(db, + new String[]{DATA, FILE_ID_COLUMN}, null, selectionArgs, null, null, null, null, + null); + }); + } + + int removeMediaGrantsForPackage(@NonNull String[] packages, @NonNull List<Uri> uris, + int packageUserId) { + Objects.requireNonNull(packages); + Objects.requireNonNull(uris); + if (packages.length == 0) { + throw new IllegalArgumentException( + "Removing grants requires a non empty package name."); + } + + return mExternalDatabase.runWithTransaction( + (db) -> { + // create a temporary table to be used as a selection criteria for local ids. + createTempTableWithLocalIdsAsColumn(uris, db); + + // Create query builder and add selection args. + final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setDistinct(true); + queryBuilder.setTables(MEDIA_GRANTS_TABLE); + String[] selectionArgs = buildSelectionArg(queryBuilder, + QueryFilterBuilder.newInstance() + .setPackageNameSelection(packages) + .setUserIdSelection(packageUserId) + .setUriSelection(uris) + .build()); + // execute query. + int grantsRemoved = queryBuilder.delete(db, null, selectionArgs); + Log.d( + TAG, + String.format( + "Removed %s media_grants for %s user for %s.", + grantsRemoved, + String.valueOf(packageUserId), + Arrays.toString(packages))); + // Drop the temporary table. + deleteTempTableCreatedForLocalIdSelection(db); + return grantsRemoved; + }); + } + + private static void createTempTableWithLocalIdsAsColumn(@NonNull List<Uri> uris, + @NonNull SQLiteDatabase db) { + + // create a temporary table and insert the ids from received uris. + db.execSQL(String.format(CREATE_TEMPORARY_TABLE_QUERY + "%s (%s INTEGER)", + TEMP_TABLE_NAME_FOR_DELETION, FILE_ID_COLUMN)); + + final SQLiteQueryBuilder queryBuilderTempTable = new SQLiteQueryBuilder(); + queryBuilderTempTable.setTables(TEMP_TABLE_NAME_FOR_DELETION); + + List<List<Uri>> listOfSelectionArgsForId = splitArrayList(uris, + /* number of ids per query */ 50); + + StringBuilder sb = new StringBuilder(); + List<Uri> selectionArgForIdSelection; + for (int itr = 0; itr < listOfSelectionArgsForId.size(); itr++) { + selectionArgForIdSelection = listOfSelectionArgsForId.get(itr); + if (itr == 0 || selectionArgForIdSelection.size() != listOfSelectionArgsForId.get( + itr - 1).size()) { + sb.setLength(0); + for (int i = 0; i < selectionArgForIdSelection.size() - 1; i++) { + sb.append("(?)").append(","); + } + sb.append("(?)"); + } + db.execSQL("INSERT INTO " + TEMP_TABLE_NAME_FOR_DELETION + " VALUES " + sb.toString(), + selectionArgForIdSelection.stream().map( + ContentUris::parseId).collect(Collectors.toList()).stream().toArray()); + } + } + + private static <T> List<List<T>> splitArrayList(List<T> list, int chunkSize) { + List<List<T>> subLists = new ArrayList<>(); + for (int i = 0; i < list.size(); i += chunkSize) { + subLists.add(list.subList(i, Math.min(i + chunkSize, list.size()))); + } + return subLists; + } + + private static void deleteTempTableCreatedForLocalIdSelection(SQLiteDatabase db) { + db.execSQL("DROP TABLE " + TEMP_TABLE_NAME_FOR_DELETION); + } + + /** * Removes any existing media grants for the given package from the external database. This will * not alter the files or file metadata themselves. * @@ -111,32 +275,37 @@ class MediaGrants { * * <p>The action is performed for only specific {@code user}.</p> * - * @param packageName the package name to clear media grants for. + * @param packages the package(s) name to clear media grants for. * @param reason a logged reason why the grants are being cleared. * @param user the user for which the grants need to be modified. * * @return the number of grants removed. */ - int removeAllMediaGrantsForPackage(String packageName, String reason, - @NonNull Integer user) + int removeAllMediaGrantsForPackages(String[] packages, String reason, @NonNull Integer user) throws IllegalArgumentException { - Objects.requireNonNull(packageName); - if (TextUtils.isEmpty(packageName)) { + Objects.requireNonNull(packages); + if (packages.length == 0) { throw new IllegalArgumentException( "Removing grants requires a non empty package name."); } + + final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setDistinct(true); + queryBuilder.setTables(MEDIA_GRANTS_TABLE); + String[] selectionArgs = buildSelectionArg(queryBuilder, QueryFilterBuilder.newInstance() + .setPackageNameSelection(packages) + .setUserIdSelection(user) + .build()); return mExternalDatabase.runWithTransaction( (db) -> { - int grantsRemoved = - mQueryBuilder.delete( - db, String.format( - "%s = ? AND %s = ?", OWNER_PACKAGE_NAME_COLUMN, - PACKAGE_USER_ID_COLUMN), - new String[]{packageName, String.valueOf(user)}); - Log.d(TAG, - String.format("Removed %s media_grants for %s user for %s. Reason: %s", - grantsRemoved, String.valueOf(user), - packageName, + int grantsRemoved = queryBuilder.delete(db, null, selectionArgs); + Log.d( + TAG, + String.format( + "Removed %s media_grants for %s user for %s. Reason: %s", + grantsRemoved, + String.valueOf(user), + Arrays.toString(packages), reason)); return grantsRemoved; }); @@ -188,7 +357,204 @@ class MediaGrants { return isPickerUri(uri) && PickerUriResolver.unwrapProviderUri(uri) - .getHost() - .equals(PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); + .getHost() + .equals(PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); + } + + /** + * Add required selection arguments like comparisons and WHERE checks to the + * {@link SQLiteQueryBuilder} qb. + * + * @param qb query builder on which the conditions/filters needs to be applied. + * @param queryFilter representing the types of selection arguments to be applied. + * @return array of selection args used to replace placeholders in query builder conditions. + */ + private String[] buildSelectionArg(SQLiteQueryBuilder qb, MediaGrantsQueryFilter queryFilter) { + List<String> selectArgs = new ArrayList<>(); + // Append where clause for package names. + if (queryFilter.mPackageNames != null && queryFilter.mPackageNames.length > 0) { + // Append the where clause for package name selection to the query builder. + qb.appendWhereStandalone( + WHERE_MEDIA_GRANTS_PACKAGE_NAME_IN + buildPlaceholderForWhereClause( + queryFilter.mPackageNames.length)); + + // Add package names to selection args. + selectArgs.addAll(Arrays.asList(queryFilter.mPackageNames)); + } + + // Append Where clause for Uris + if (queryFilter.mUris != null && !queryFilter.mUris.isEmpty()) { + // Append the where clause for local id selection to the query builder. + // this query would look like this example query: + // WHERE EXISTS (SELECT 1 from temp_table_for_media_grants_deletion WHERE + // temp_table_for_media_grants_deletion.file_id = media_grants.file_id) + qb.appendWhereStandalone(String.format("EXISTS (SELECT %s from %s WHERE %s = %s)", + TEMP_TABLE_FOR_DELETION_FILE_ID_COLUMN_NAME, + TEMP_TABLE_NAME_FOR_DELETION, + TEMP_TABLE_FOR_DELETION_FILE_ID_COLUMN_NAME, + MediaGrants.MEDIA_GRANTS_TABLE + "." + MediaGrants.FILE_ID_COLUMN)); + } + + // Append where clause for userID. + if (queryFilter.mUserId != null) { + qb.appendWhereStandalone(WHERE_MEDIA_GRANTS_USER_ID); + selectArgs.add(String.valueOf(queryFilter.mUserId)); + } + + if (queryFilter.mIsNotTrashed) { + qb.appendWhereStandalone(WHERE_ITEM_IS_NOT_TRASHED); + selectArgs.add(ARG_VALUE_FOR_FALSE); + } + + if (queryFilter.mIsNotPending) { + qb.appendWhereStandalone(WHERE_ITEM_IS_NOT_PENDING); + selectArgs.add(ARG_VALUE_FOR_FALSE); + } + + if (queryFilter.mIsOnlyVisualMediaType) { + qb.appendWhereStandalone(WHERE_MEDIA_TYPE + buildPlaceholderForWhereClause( + VISUAL_MEDIA_TYPE_COUNT)); + selectArgs.add(String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE)); + selectArgs.add(String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)); + } + + if (queryFilter.mAvailableVolumes != null && queryFilter.mAvailableVolumes.length > 0) { + qb.appendWhereStandalone( + WHERE_VOLUME_NAME_IN + buildPlaceholderForWhereClause( + queryFilter.mAvailableVolumes.length)); + selectArgs.addAll(Arrays.asList(queryFilter.mAvailableVolumes)); + } + + addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectArgs, queryFilter.mMimeTypeSelection); + + return selectArgs.toArray(new String[selectArgs.size()]); + } + + private void addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb, + List<String> selectionArgs, String[] mimeTypes) { + if (mimeTypes == null) { + return; + } + + mimeTypes = replaceMatchAnyChar(mimeTypes); + ArrayList<String> whereMimeTypes = new ArrayList<>(); + for (String mimeType : mimeTypes) { + if (!TextUtils.isEmpty(mimeType)) { + whereMimeTypes.add(WHERE_MIME_TYPE); + selectionArgs.add(mimeType); + } + } + + if (whereMimeTypes.isEmpty()) { + return; + } + qb.appendWhereStandalone(TextUtils.join(" OR ", whereMimeTypes)); + } + + private String buildPlaceholderForWhereClause(int numberOfItemsInSelection) { + StringBuilder placeholder = new StringBuilder("("); + for (int itr = 0; itr < numberOfItemsInSelection; itr++) { + placeholder.append("?,"); + } + placeholder.deleteCharAt(placeholder.length() - 1); + placeholder.append(")"); + return placeholder.toString(); + } + + static final class MediaGrantsQueryFilter { + + private final List<Uri> mUris; + private final String[] mPackageNames; + private final Integer mUserId; + + private final boolean mIsNotTrashed; + + private final boolean mIsNotPending; + + private final boolean mIsOnlyVisualMediaType; + private final String[] mMimeTypeSelection; + + private final String[] mAvailableVolumes; + + MediaGrantsQueryFilter(QueryFilterBuilder builder) { + this.mUris = builder.mUris; + this.mPackageNames = builder.mPackageNames; + this.mUserId = builder.mUserId; + this.mIsNotTrashed = builder.mIsNotTrashed; + this.mIsNotPending = builder.mIsNotPending; + this.mMimeTypeSelection = builder.mMimeTypeSelection; + this.mIsOnlyVisualMediaType = builder.mIsOnlyVisualMediaType; + this.mAvailableVolumes = builder.mAvailableVolumes; + } + } + + // Static class Builder + static class QueryFilterBuilder { + + private List<Uri> mUris; + private String[] mPackageNames; + private int mUserId; + + private boolean mIsNotTrashed; + + private boolean mIsNotPending; + + private boolean mIsOnlyVisualMediaType; + private String[] mMimeTypeSelection; + + private String[] mAvailableVolumes; + + public static QueryFilterBuilder newInstance() { + return new QueryFilterBuilder(); + } + + private QueryFilterBuilder() {} + + // Setter methods + public QueryFilterBuilder setUriSelection(List<Uri> uris) { + this.mUris = uris; + return this; + } + + public QueryFilterBuilder setPackageNameSelection(String[] packageNames) { + this.mPackageNames = packageNames; + return this; + } + + public QueryFilterBuilder setUserIdSelection(int userId) { + this.mUserId = userId; + return this; + } + + public QueryFilterBuilder setIsNotTrashedSelection(boolean isNotTrashed) { + this.mIsNotTrashed = isNotTrashed; + return this; + } + + public QueryFilterBuilder setIsNotPendingSelection(boolean isNotPending) { + this.mIsNotPending = isNotPending; + return this; + } + + public QueryFilterBuilder setIsOnlyVisualMediaType(boolean isOnlyVisualMediaType) { + this.mIsOnlyVisualMediaType = isOnlyVisualMediaType; + return this; + } + + public QueryFilterBuilder setMimeTypeSelection(String[] mimeTypeSelection) { + this.mMimeTypeSelection = mimeTypeSelection; + return this; + } + + public QueryFilterBuilder setAvailableVolumes(String[] availableVolumes) { + this.mAvailableVolumes = availableVolumes; + return this; + } + + // build method to deal with outer class + // to return outer instance + public MediaGrantsQueryFilter build() { + return new MediaGrantsQueryFilter(this); + } } } diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index a93f5303a..69a9d0247 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -37,6 +37,7 @@ import static android.provider.MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE; import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT; import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE; import static android.provider.MediaStore.GET_BACKUP_FILES; +import static android.provider.MediaStore.GET_OWNER_PACKAGE_NAME; import static android.provider.MediaStore.MATCH_DEFAULT; import static android.provider.MediaStore.MATCH_EXCLUDE; import static android.provider.MediaStore.MATCH_INCLUDE; @@ -51,7 +52,7 @@ import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING; import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED; import static android.provider.MediaStore.QUERY_ARG_REDACTED_URI; import static android.provider.MediaStore.QUERY_ARG_RELATED_URI; -import static android.provider.MediaStore.READ_BACKED_UP_FILE_PATHS; +import static android.provider.MediaStore.READ_BACKUP; import static android.provider.MediaStore.getVolumeName; import static android.system.OsConstants.F_GETFL; @@ -60,7 +61,6 @@ import static com.android.providers.media.AccessChecker.getWhereForOwnerPackageM import static com.android.providers.media.AccessChecker.getWhereForUserSelectedAccess; import static com.android.providers.media.AccessChecker.hasAccessToCollection; import static com.android.providers.media.AccessChecker.hasUserSelectedAccess; -import static com.android.providers.media.DatabaseBackupAndRecovery.LEVEL_DB_READ_LIMIT; import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME; import static com.android.providers.media.DatabaseHelper.INTERNAL_DATABASE_NAME; import static com.android.providers.media.LocalCallingIdentity.APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID; @@ -107,6 +107,7 @@ import static com.android.providers.media.LocalUriMatcher.IMAGES_MEDIA_ID; import static com.android.providers.media.LocalUriMatcher.IMAGES_MEDIA_ID_THUMBNAIL; import static com.android.providers.media.LocalUriMatcher.IMAGES_THUMBNAILS; import static com.android.providers.media.LocalUriMatcher.IMAGES_THUMBNAILS_ID; +import static com.android.providers.media.LocalUriMatcher.MEDIA_GRANTS; import static com.android.providers.media.LocalUriMatcher.MEDIA_SCANNER; import static com.android.providers.media.LocalUriMatcher.PICKER_ID; import static com.android.providers.media.LocalUriMatcher.PICKER_INTERNAL_ALBUMS_ALL; @@ -122,6 +123,7 @@ import static com.android.providers.media.LocalUriMatcher.VIDEO_THUMBNAILS_ID; import static com.android.providers.media.LocalUriMatcher.VOLUMES; import static com.android.providers.media.LocalUriMatcher.VOLUMES_ID; import static com.android.providers.media.PickerUriResolver.getMediaUri; +import static com.android.providers.media.photopicker.data.ItemsProvider.EXTRA_MIME_TYPE_SELECTION; import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND; import static com.android.providers.media.scan.MediaScanner.REASON_IDLE; import static com.android.providers.media.util.DatabaseUtils.bindList; @@ -164,6 +166,7 @@ import static com.android.providers.media.util.SyntheticPathUtils.isSyntheticPat import android.Manifest; import android.annotation.IntDef; +import android.app.ActivityOptions; import android.app.AppOpsManager; import android.app.AppOpsManager.OnOpActiveChangedListener; import android.app.AppOpsManager.OnOpChangedListener; @@ -248,6 +251,7 @@ import android.provider.MediaStore.Images; import android.provider.MediaStore.Images.ImageColumns; import android.provider.MediaStore.MediaColumns; import android.provider.MediaStore.Video; +import android.provider.Settings; import android.system.ErrnoException; import android.system.Os; import android.system.OsConstants; @@ -284,10 +288,13 @@ import com.android.providers.media.photopicker.PickerDataLayer; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.ExternalDbFacade; import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.data.PickerSyncRequestExtras; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; import com.android.providers.media.playlist.Playlist; import com.android.providers.media.scan.MediaScanner; import com.android.providers.media.scan.MediaScanner.ScanReason; import com.android.providers.media.scan.ModernMediaScanner; +import com.android.providers.media.stableuris.dao.BackupIdRow; import com.android.providers.media.util.CachedSupplier; import com.android.providers.media.util.DatabaseUtils; import com.android.providers.media.util.FileUtils; @@ -309,6 +316,8 @@ import com.android.providers.media.util.XmpInterface; import com.google.common.base.Strings; import com.google.common.hash.Hashing; +import org.jetbrains.annotations.NotNull; + import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; @@ -488,6 +497,14 @@ public class MediaProvider extends ContentProvider { */ private static final String DOWNLOADS_PROVIDER_AUTHORITY = "downloads"; + private static final String DEFAULT_FOLDER_CREATED_KEY_PREFIX = "created_default_folders_"; + + /** + * This value should match android.os.Trace.MAX_SECTION_NAME_LEN , not accessible from this + * class + */ + private static final int MAX_SECTION_NAME_LEN = 127; + @GuardedBy("mPendingOpenInfo") private final Map<Integer, PendingOpenInfo> mPendingOpenInfo = new ArrayMap<>(); @@ -649,19 +666,22 @@ public class MediaProvider extends ContentProvider { Context context = getContext(); PackageManager packageManager = context.getPackageManager(); try { - int uid = packageManager.getPackageUidAsUser(packageName, - PackageManager.PackageInfoFlags.of(0), userId); - if (!LocalCallingIdentity.fromExternal(context, mUserCache, uid) - .checkCallingPermissionUserSelected()) { - // Revoke media grants if permission state is not "Select flow". - mMediaGrants.removeAllMediaGrantsForPackage( - packageName, - /*reason=*/ "Mode changed: " + op, - userId); + int uid = + packageManager.getPackageUidAsUser( + packageName, PackageManager.PackageInfoFlags.of(0), userId); + LocalCallingIdentity lci = LocalCallingIdentity.fromExternal(context, mUserCache, uid); + if (!lci.checkCallingPermissionUserSelected()) { + String[] packages = lci.getSharedPackageNamesArray(); + mMediaGrants.removeAllMediaGrantsForPackages( + packages, /* reason= */ "Mode changed: " + op, userId); } } catch (NameNotFoundException e) { - Log.d(TAG, "Unable to resolve uid. Ignoring the AppOp change for " - + packageName + ", User : " + userId); + Log.d( + TAG, + "Unable to resolve uid. Ignoring the AppOp change for " + + packageName + + ", User : " + + userId); } } @@ -804,6 +824,11 @@ public class MediaProvider extends ContentProvider { * isMediaSharedWithParent is true.On removal of such user profile, * the owner's MediaProvider would need to clean any media files stored * by the removed user profile. + * We also remove the default folder key for the cloned user (just removed) + * from user 0's SharedPreferences. Usually, the next clone user would be + * created with a different key (as user-id would be incremented), however, if + * device is restarted, the next clone-user can use the user-id previously + * assigned, causing stale entries in user 0's SharedPreferences */ UserHandle userToBeRemoved = intent.getParcelableExtra(Intent.EXTRA_USER); if(userToBeRemoved.getIdentifier() != sUserId){ @@ -812,6 +837,43 @@ public class MediaProvider extends ContentProvider { new String[]{String.valueOf(userToBeRemoved.getIdentifier())}); return null ; }); + String userToBeRemovedVolId = null; + synchronized (mAttachedVolumes) { + for (MediaVolume volume : mAttachedVolumes) { + if (userToBeRemoved.equals(volume.getUser())) { + userToBeRemovedVolId = volume.getId(); + break; + } + } + } + //The clone user volume may be unmounted at this time (userToBeRemovedVolId + // will be null then), we construct the volId of unmounted vol from userId. + String key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + + getPrimaryVolumeId(userToBeRemovedVolId, userToBeRemoved); + final SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(getContext()); + if (prefs.getInt(key, /* default */ 0) == 1) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(key); + editor.commit(); + } + } + + boolean isDeviceInDemoMode = false; + try { + isDeviceInDemoMode = Settings.Global.getInt( + getContext().getContentResolver(), Settings.Global.DEVICE_DEMO_MODE) + > 0; + } catch (Settings.SettingNotFoundException e) { + Log.w(TAG, "Exception in reading DEVICE_DEMO_MODE setting", e); + } + + Log.i(TAG, "isDeviceInDemoMode: " + isDeviceInDemoMode); + // Only allow default system user 0 to update xattrs on /data/media/0 and + // only on retail demo devices + if (sUserId == UserHandle.SYSTEM.getIdentifier() && isDeviceInDemoMode) { + mDatabaseBackupAndRecovery.removeRecoveryDataForUserId( + userToBeRemoved.getIdentifier()); } break; } @@ -831,17 +893,33 @@ public class MediaProvider extends ContentProvider { } } - private void updateQuotaTypeForUri(@NonNull Uri uri, int mediaType, - @NonNull String volumeName) { + protected void updateQuotaTypeForUri(@NonNull FileRow row) { + final String volumeName = row.getVolumeName(); + final String path = row.getPath(); + // Quota type is only updated for external primary volume if (!MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName)) { return; } + int mediaType = row.getMediaType(); Trace.beginSection("MP.updateQuotaTypeForUri"); File file; try { - file = queryForDataFile(uri, null); + if (path != null) { + file = new File(path); + } else { + // This can happen in case of renames, where the path isn't + // part of the 'new' FileRow data. Fall back to querying + // the path directly. + final Uri uri = MediaStore.Files.getContentUri(row.getVolumeName(), + row.getId()); + if (uri == null) { + // Row could have been deleted + return; + } + file = queryForDataFile(uri, null); + } if (!file.exists()) { // This can happen if an item is inserted in MediaStore before it is created return; @@ -857,7 +935,7 @@ public class MediaProvider extends ContentProvider { updateQuotaTypeForFileInternal(file, mediaType); } catch (FileNotFoundException | IllegalArgumentException e) { // Ignore - Log.w(TAG, "Failed to update quota for uri: " + uri, e); + Log.w(TAG, "Failed to update quota", e); } finally { Trace.endSection(); } @@ -913,8 +991,7 @@ public class MediaProvider extends ContentProvider { // Update the quota type on the filesystem Uri fileUri = MediaStore.Files.getContentUri(insertedRow.getVolumeName(), insertedRow.getId()); - updateQuotaTypeForUri(fileUri, insertedRow.getMediaType(), - insertedRow.getVolumeName()); + updateQuotaTypeForUri(insertedRow); } // Tell our SAF provider so it knows when views are no longer empty @@ -923,7 +1000,7 @@ public class MediaProvider extends ContentProvider { if (mExternalDbFacade.onFileInserted(insertedRow.getMediaType(), insertedRow.isPending())) { - mPickerSyncController.notifyMediaEvent(); + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); } mDatabaseBackupAndRecovery.backupVolumeDbData(helper, insertedRow); @@ -953,7 +1030,7 @@ public class MediaProvider extends ContentProvider { helper.postBackground(() -> { if (helper.isExternal()) { // Update the quota type on the filesystem - updateQuotaTypeForUri(fileUri, newRow.getMediaType(), oldRow.getVolumeName()); + updateQuotaTypeForUri(newRow); } if (mExternalDbFacade.onFileUpdated(oldRow.getId(), @@ -962,7 +1039,7 @@ public class MediaProvider extends ContentProvider { oldRow.isPending(), newRow.isPending(), oldRow.isFavorite(), newRow.isFavorite(), oldRow.getSpecialFormat(), newRow.getSpecialFormat())) { - mPickerSyncController.notifyMediaEvent(); + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); } mDatabaseBackupAndRecovery.updateBackup(helper, oldRow, newRow); @@ -1022,7 +1099,7 @@ public class MediaProvider extends ContentProvider { if (mExternalDbFacade.onFileDeleted(deletedRow.getId(), deletedRow.getMediaType())) { - mPickerSyncController.notifyMediaEvent(); + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); } mDatabaseBackupAndRecovery.deleteFromDbBackup(helper, deletedRow); @@ -1128,11 +1205,12 @@ public class MediaProvider extends ContentProvider { if (volumeName.equals(MediaStore.VOLUME_EXTERNAL_PRIMARY)) { // For the primary volume, we use the ID, because we may be handling // the primary volume for multiple users - key = "created_default_folders_" + volume.getId(); + key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + + getPrimaryVolumeId(volume.getId(), volume.getUser()); } else { // For others, like public volumes, just use the name, because the id // might not change when re-formatted - key = "created_default_folders_" + volumeName; + key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + volumeName; } final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -1152,6 +1230,24 @@ public class MediaProvider extends ContentProvider { } /** + * Returns the volume id for Primary External Volumes. + * If volId is supplied, it is returned as-is, in case it is not, user-id is used to + * construct the id for Primary External Volume. + * + * @param volId the id of the Volume in consideration. + * @param userId userId for which primary volume id needs to be determined. + * @return the primary volume id. + */ + private String getPrimaryVolumeId(String volId, UserHandle userId) { + if (volId == null) { + // The construction is based upon system/vold/model/EmulatedVolume.cpp + // Should be kept in sync with the same. + return "emulated;" + userId.getIdentifier(); + } + return volId; + } + + /** * Ensure that any thumbnail collections on the given storage volume can be * used with the given {@link DatabaseHelper}. If the * {@link DatabaseHelper#getOrCreateUuid} doesn't match the UUID found on @@ -1251,12 +1347,15 @@ public class MediaProvider extends ContentProvider { mProjectionHelper, Metrics::logSchemaChange, mFilesListener, MIGRATION_LISTENER, mIdGenerator, true, mDatabaseBackupAndRecovery); mExternalDbFacade = new ExternalDbFacade(getContext(), mExternalDatabase, mVolumeCache); - mPickerDbFacade = new PickerDbFacade(context); mMediaGrants = new MediaGrants(mExternalDatabase); - mPickerSyncController = new PickerSyncController(context, mPickerDbFacade, mConfigStore); - mPickerDataLayer = new PickerDataLayer(context, mPickerDbFacade, mPickerSyncController); + PickerSyncLockManager pickerSyncLockManager = new PickerSyncLockManager(); + mPickerDbFacade = new PickerDbFacade(context, pickerSyncLockManager); + mPickerSyncController = PickerSyncController.initialize(context, mPickerDbFacade, + mConfigStore, pickerSyncLockManager); + mPickerDataLayer = PickerDataLayer.create(context, mPickerDbFacade, mPickerSyncController, + mConfigStore); mPickerUriResolver = new PickerUriResolver(context, mPickerDbFacade, mProjectionHelper); if (SdkLevel.isAtLeastS()) { @@ -1265,9 +1364,6 @@ public class MediaProvider extends ContentProvider { mTranscodeHelper = new TranscodeHelperNoOp(); } - // Create dir for redacted and picker URI paths. - buildPrimaryVolumeFile(uidToUserId(MY_UID), getRedactedRelativePath()).mkdirs(); - final IntentFilter packageFilter = new IntentFilter(); packageFilter.setPriority(10); packageFilter.addDataScheme("package"); @@ -1300,9 +1396,9 @@ public class MediaProvider extends ContentProvider { } updateVolumes(); - attachVolume(MediaVolume.fromInternal(), /* validate */ false); + attachVolume(MediaVolume.fromInternal(), /* validate */ false, /* volumeState */ null); for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { - attachVolume(volume, /* validate */ false); + attachVolume(volume, /* validate */ false, /* volumeState */ null); } // Watch for performance-sensitive activity @@ -1366,11 +1462,6 @@ public class MediaProvider extends ContentProvider { mConfigStore.addOnChangeListener( BackgroundThread.getExecutor(), this::storageNativeBootPropertyChangeListener); - // media_grants are cleared on device reboot, and onCreate is a good signal for this. - ForegroundThread.getExecutor().execute(() -> { - mMediaGrants.removeAllMediaGrants(); - }); - PulledMetrics.initialize(context); return true; } @@ -1380,6 +1471,8 @@ public class MediaProvider extends ContentProvider { boolean isGetContentTakeoverEnabled; if (SdkLevel.isAtLeastT()) { isGetContentTakeoverEnabled = true; + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + isGetContentTakeoverEnabled = true; } else { isGetContentTakeoverEnabled = mConfigStore.isGetContentTakeOverEnabled(); } @@ -1387,8 +1480,6 @@ public class MediaProvider extends ContentProvider { setComponentEnabledSetting("PhotoPickerUserSelectActivity", mConfigStore.isUserSelectForAppEnabled()); - - mDatabaseBackupAndRecovery.onConfigPropertyChangeListener(); } public DatabaseBackupAndRecovery getDatabaseBackupAndRecovery() { @@ -1458,10 +1549,25 @@ public class MediaProvider extends ContentProvider { } // Second, is the app pending, probably from a backup/restore operation? - for (SessionInfo si : pm.getPackageInstaller().getAllSessions()) { - if (Objects.equals(packageName, si.getAppPackageName())) { + // Cloned app installations do not have a linked install session, so skipping the check in + // case the user-id is a clone profile. + if (!isAppCloneUserForFuse(userId)) { + if (sUserId != userId) { + // Skip the package check and ensure media provider doesn't crash + // Returning true since we are unsure what caused the cross-user entries to be in + // the database and want to avoid deleting data that might be required. + Log.e(TAG, "Skip pruning cross-user entries stored in database for package: " + + packageName + " userId: " + userId + " processUserId: " + sUserId); return true; } + for (SessionInfo si : pm.getPackageInstaller().getAllSessions()) { + if (Objects.equals(packageName, si.getAppPackageName())) { + return true; + } + } + } else { + Log.e(TAG, "Cross-user entries found in database for package " + packageName + + " userId: " + userId + " processUserId: " + sUserId); } // I've never met this package in my life @@ -1480,8 +1586,8 @@ public class MediaProvider extends ContentProvider { try { MediaService.onScanVolume(getContext(), volume, REASON_IDLE); - } catch (IOException e) { - Log.w(TAG, e); + } catch (IOException | IllegalArgumentException e) { + Log.w(TAG, "Failure in " + volume.getName() + " volume scan", e); } // Ensure that our thumbnails are valid @@ -1543,11 +1649,13 @@ public class MediaProvider extends ContentProvider { // removing calling userId userIds.remove(String.valueOf(sUserId)); + + List<String> validUserProfiles = mUserManager.getEnabledProfiles().stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); // removing all the valid/existing user, remaining userIds would be users who would have // been removed - userIds.removeAll(mUserManager.getEnabledProfiles().stream() - .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( - Collectors.toList())); + userIds.removeAll(validUserProfiles); // Cleaning media files of users who have been removed mExternalDatabase.runWithTransaction((db) -> { @@ -1558,6 +1666,25 @@ public class MediaProvider extends ContentProvider { }); return null ; }); + + boolean isDeviceInDemoMode = false; + try { + isDeviceInDemoMode = Settings.Global.getInt(getContext().getContentResolver(), + Settings.Global.DEVICE_DEMO_MODE) > 0; + } catch (Settings.SettingNotFoundException e) { + Log.w(TAG, "Exception in reading DEVICE_DEMO_MODE setting", e); + } + + Log.i(TAG, "isDeviceInDemoMode: " + isDeviceInDemoMode); + // Only allow default system user 0 to update xattrs on /data/media/0 and only when + // device is in retail mode + if (sUserId == UserHandle.SYSTEM.getIdentifier() && isDeviceInDemoMode) { + List<String> validUsers = mUserManager.getUserHandles(/* excludeDying */ true).stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); + Log.i(TAG, "Active user ids are:" + validUsers); + mDatabaseBackupAndRecovery.removeRecoveryDataExceptValidUsers(validUsers); + } } private void pruneStalePackages(CancellationSignal signal) { @@ -1873,6 +2000,10 @@ public class MediaProvider extends ContentProvider { mExternalDatabase.runWithTransaction((db) -> { final int userId = uid / PER_USER_RANGE; onPackageOrphaned(db, packageName, userId); + + if (SdkLevel.isAtLeastU()) { + removeAllMediaGrantsForUid(uid, userId, packageName); + } return null; }); } @@ -1888,9 +2019,42 @@ public class MediaProvider extends ContentProvider { // Orphan rest of entries. orphanEntries(db, packageName, userId); mDatabaseBackupAndRecovery.removeOwnerIdToPackageRelation(packageName, userId); - // TODO(b/260685885): Add e2e tests to ensure these are cleared when a package is removed. - mMediaGrants.removeAllMediaGrantsForPackage(packageName, /* reason */ "Package orphaned", - userId); + + } + + /** + * Removes all media_grants for all packages with the given UID. (i.e. shared packages.) + * + * @param uid the package uid. (will use this to query all shared packages that use this uid) + * @param userId the user id, since packages can be installed by multiple users. + * @param additionalPackageName An optional additional package name in the event that the + * package has been removed at won't be returned by the PackageManager APIs. + */ + private void removeAllMediaGrantsForUid( + int uid, int userId, @Nullable String additionalPackageName) { + + String[] packages; + try { + LocalCallingIdentity lci = + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid); + packages = lci.getSharedPackageNamesArray(); + } catch (IllegalArgumentException notFound) { + // If there are no packages found, this means the specified UID has no packages + // remaining on the system. + packages = new String[]{}; + } + if (additionalPackageName != null) { + // Include the passed additional package in the list LocalCallingIdentity returns. + List<String> packageList = new ArrayList<>(); + packageList.addAll(Arrays.asList(packages)); + packageList.add(additionalPackageName); + packages = packageList.toArray(new String[packageList.size()]); + } + + // TODO(b/260685885): Add e2e tests to ensure these are cleared when a package + // is removed. + mMediaGrants.removeAllMediaGrantsForPackages( + packages, /* reason */ "Package orphaned", userId); } private void deleteAndroidMediaEntries(SQLiteDatabase db, String packageName, int userId) { @@ -2449,15 +2613,15 @@ public class MediaProvider extends ContentProvider { } @Override - public Uri canonicalize(Uri uri) { - final boolean allowHidden = isCallingPackageAllowedHidden(); - final int match = matchUri(uri, allowHidden); - + public Uri canonicalize(@NonNull Uri uri) { // Skip when we have nothing to canonicalize if ("1".equals(uri.getQueryParameter(CANONICAL))) { return uri; } + final boolean allowHidden = mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF); + final int match = matchUri(uri, allowHidden); + try (Cursor c = queryForSingleItem(uri, null, null, null, null)) { switch (match) { case AUDIO_MEDIA_ID: { @@ -2490,14 +2654,13 @@ public class MediaProvider extends ContentProvider { } @Override - public Uri uncanonicalize(Uri uri) { - final boolean allowHidden = isCallingPackageAllowedHidden(); - final int match = matchUri(uri, allowHidden); - + public Uri uncanonicalize(@NonNull Uri uri) { // Skip when we have nothing to uncanonicalize if (!"1".equals(uri.getQueryParameter(CANONICAL))) { return uri; } + final boolean allowHidden = mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF); + final int match = matchUri(uri, allowHidden); // Extract values and then clear to avoid recursive lookups final String title = uri.getQueryParameter(AudioColumns.TITLE); @@ -2561,6 +2724,14 @@ public class MediaProvider extends ContentProvider { return uri; } + private static String safeTraceSectionNameWithUri(String operation, Uri uri) { + String sectionName = "MP." + operation + " [" + uri + "]"; + if (sectionName.length() > MAX_SECTION_NAME_LEN) { + return sectionName.substring(0, MAX_SECTION_NAME_LEN); + } + return sectionName; + } + /** * @return where clause to exclude database rows where * <ul> @@ -3446,20 +3617,21 @@ public class MediaProvider extends ContentProvider { } @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, - String sortOrder) { + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { return query(uri, projection, DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, sortOrder), null); } @Override - public Cursor query(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal) { + public Cursor query(@NonNull Uri uri, String[] projection, Bundle queryArgs, + CancellationSignal signal) { return query(uri, projection, queryArgs, signal, /* forSelf */ false); } private Cursor query(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal, boolean forSelf) { - Trace.beginSection("MP.query [" + uri + ']'); + Trace.beginSection(safeTraceSectionNameWithUri("query", uri)); try { return queryInternal(uri, projection, queryArgs, signal, forSelf); } catch (FallbackException e) { @@ -3501,6 +3673,10 @@ public class MediaProvider extends ContentProvider { final boolean allowHidden = isCallingPackageAllowedHidden(); final int table = matchUri(uri, allowHidden); + if (table == MEDIA_GRANTS) { + return getReadGrantedMediaForPackage(queryArgs); + } + // handle MEDIA_SCANNER before calling getDatabaseForUri() if (table == MEDIA_SCANNER) { // create a cursor to return volume currently being scanned by the media scanner @@ -3651,6 +3827,31 @@ public class MediaProvider extends ContentProvider { return c; } + @NotNull + private Cursor getReadGrantedMediaForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + String[] packageNames; + if (!checkPermissionSelf(caller)) { + // All other callers are unauthorized. + throw new SecurityException( + getSecurityExceptionMessage("read media grants")); + } + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + packageNames = pm.getPackagesForUid(packageUid); + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + String[] mimeTypes = extras.getStringArray(EXTRA_MIME_TYPE_SELECTION); + // Available volumes, to filter out any external storage that may be removed but the grants + // persisted. + String[] availableVolumes = mVolumeCache.getExternalVolumeNames().toArray(new String[0]); + return mMediaGrants.getMediaGrantsForPackages(packageNames, userId, mimeTypes, + availableVolumes); + } + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) private Set<String> getQueryablePackages(String[] packageNames) { final boolean[] canPackageBeQueried; @@ -4162,6 +4363,20 @@ public class MediaProvider extends ContentProvider { // DATA column. File volumePath; UserHandle userHandle = mCallingIdentity.get().getUser(); + Integer userIdFromPathObject = values.getAsInteger(FileColumns._USER_ID); + int userIdFromPath = (userIdFromPathObject == null ? userHandle.getIdentifier() : + userIdFromPathObject); + // In case if the _user_id column is set, and is different from the userHandle + // determined from mCallingIdentity, we prefer the former, as it comes from the original + // path provided to MP process. + // Normally this does not create any issues, but when cloned profile is active, an app + // in root user can try to create an image file in lower file system, by specifying + // the file directory as /storage/emulated/<cloneUserId>/DCIM. For such cases, we + // would want <cloneUserId> to be used to determine path in MP entry. + if (userHandle.getIdentifier() != userIdFromPath + && isAppCloneUserPair(userHandle.getIdentifier(), userIdFromPath)) { + userHandle = UserHandle.of(userIdFromPath); + } if (currentPath != null) { int userId = FileUtils.extractUserId(currentPath); if (userId != -1) { @@ -4974,7 +5189,7 @@ public class MediaProvider extends ContentProvider { @Nullable public Uri insert(@NonNull Uri uri, @Nullable ContentValues values, @Nullable Bundle extras) { - Trace.beginSection("MP.insert [" + uri + ']'); + Trace.beginSection(safeTraceSectionNameWithUri("insert", uri)); try { try { return insertInternal(uri, values, extras); @@ -5008,7 +5223,6 @@ public class MediaProvider extends ContentProvider { final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); - final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final String resolvedVolumeName = resolveVolumeName(uri); // handle MEDIA_SCANNER before calling getDatabaseForUri() @@ -5027,7 +5241,8 @@ public class MediaProvider extends ContentProvider { MediaVolume volume = null; try { volume = getVolume(name); - Uri attachedVolume = attachVolume(volume, /* validate */ true); + Uri attachedVolume = attachVolume(volume, /* validate */ true, /* volumeState */ + null); if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) { final DatabaseHelper helper = getDatabaseForUri( MediaStore.Files.getContentUri(mMediaScannerVolume)); @@ -6027,7 +6242,7 @@ public class MediaProvider extends ContentProvider { @Override public int delete(@NonNull Uri uri, @Nullable Bundle extras) { - Trace.beginSection("MP.delete [" + uri + ']'); + Trace.beginSection(safeTraceSectionNameWithUri("delete", uri)); try { return deleteInternal(uri, extras); } catch (FallbackException e) { @@ -6086,8 +6301,6 @@ public class MediaProvider extends ContentProvider { int count = 0; - final int targetSdkVersion = getCallingPackageTargetSdkVersion(); - // handle MEDIA_SCANNER before calling getDatabaseForUri() if (match == MEDIA_SCANNER) { if (mMediaScannerVolume == null) { @@ -6383,427 +6596,668 @@ public class MediaProvider extends ContentProvider { private Bundle callInternal(String method, String arg, Bundle extras) { switch (method) { case MediaStore.RESOLVE_PLAYLIST_MEMBERS_CALL: { - final LocalCallingIdentity token = clearLocalCallingIdentity(); - final CallingIdentity providerToken = clearCallingIdentity(); - try { - final Uri playlistUri = extras.getParcelable(MediaStore.EXTRA_URI); - resolvePlaylistMembers(playlistUri); - } finally { - restoreCallingIdentity(providerToken); - restoreLocalCallingIdentity(token); - } - return null; + return getResultForResolvePlaylistMembers(extras); } case MediaStore.RUN_IDLE_MAINTENANCE_CALL: { - // Protect ourselves from random apps by requiring a generic - // permission held by common debugging components, such as shell - getContext().enforceCallingOrSelfPermission( - android.Manifest.permission.DUMP, TAG); - final LocalCallingIdentity token = clearLocalCallingIdentity(); - final CallingIdentity providerToken = clearCallingIdentity(); - try { - onIdleMaintenance(new CancellationSignal()); - } finally { - restoreCallingIdentity(providerToken); - restoreLocalCallingIdentity(token); - } - return null; + return getResultForRunIdleMaintenance(); } case MediaStore.WAIT_FOR_IDLE_CALL: { - // TODO(b/195009139): Remove after overriding wait for idle in test to sync picker - // Syncing the picker while waiting for idle fixes tests with the picker db - // flag enabled because the picker db is in a consistent state with the external - // db after the sync - syncAllMedia(); - ForegroundThread.waitForIdle(); - final CountDownLatch latch = new CountDownLatch(1); - BackgroundThread.getExecutor().execute(latch::countDown); - try { - latch.await(30, TimeUnit.SECONDS); - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } - return null; + return getResultForWaitForIdle(); } case MediaStore.SCAN_FILE_CALL: { - final LocalCallingIdentity token = clearLocalCallingIdentity(); - final CallingIdentity providerToken = clearCallingIdentity(); - - final String filePath = arg; - final Uri uri; - try { - File file; - try { - file = FileUtils.getCanonicalFile(filePath); - } catch (IOException e) { - file = null; - } - - uri = file != null ? scanFile(file, REASON_DEMAND) : null; - } finally { - restoreCallingIdentity(providerToken); - restoreLocalCallingIdentity(token); - } - - // TODO(b/262244882): maybe enforceCallingPermissionInternal(uri, ...) - - final Bundle res = new Bundle(); - res.putParcelable(Intent.EXTRA_STREAM, uri); - return res; + return getResultForScanFile(arg); } case MediaStore.SCAN_VOLUME_CALL: { - final int userId = uidToUserId(Binder.getCallingUid()); - final LocalCallingIdentity token = clearLocalCallingIdentity(); - final CallingIdentity providerToken = clearCallingIdentity(); - - final String volumeName = arg; - try { - final MediaVolume volume = mVolumeCache.findVolume(volumeName, - UserHandle.of(userId)); - MediaService.onScanVolume(getContext(), volume, REASON_DEMAND); - } catch (FileNotFoundException e) { - Log.w(TAG, "Failed to find volume " + volumeName, e); - } catch (IOException e) { - throw new RuntimeException(e); - } finally { - restoreCallingIdentity(providerToken); - restoreLocalCallingIdentity(token); - } - return Bundle.EMPTY; + return getResultForScanVolume(arg); } case MediaStore.GET_VERSION_CALL: { - final String volumeName = extras.getString(Intent.EXTRA_TEXT); - - final DatabaseHelper helper; - try { - helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName)); - } catch (VolumeNotFoundException e) { - throw e.rethrowAsIllegalArgumentException(); - } - - final String version = helper.runWithoutTransaction((db) -> - db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db)); - - final Bundle res = new Bundle(); - res.putString(Intent.EXTRA_TEXT, version); - return res; + return getResultForGetVersion(extras); } case MediaStore.GET_GENERATION_CALL: { - final String volumeName = extras.getString(Intent.EXTRA_TEXT); - - final DatabaseHelper helper; - try { - helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName)); - } catch (VolumeNotFoundException e) { - throw e.rethrowAsIllegalArgumentException(); - } - - final long generation = helper.runWithoutTransaction(DatabaseHelper::getGeneration); - - final Bundle res = new Bundle(); - res.putLong(Intent.EXTRA_INDEX, generation); - return res; + return getResultForGetGeneration(extras); } case MediaStore.GET_DOCUMENT_URI_CALL: { - final Uri mediaUri = extras.getParcelable(MediaStore.EXTRA_URI); - enforceCallingPermission(mediaUri, extras, false); - - final Uri fileUri; - final LocalCallingIdentity token = clearLocalCallingIdentity(); - try { - fileUri = Uri.fromFile(queryForDataFile(mediaUri, null)); - } catch (FileNotFoundException e) { - throw new IllegalArgumentException(e); - } finally { - restoreLocalCallingIdentity(token); - } - - try (ContentProviderClient client = getContext().getContentResolver() - .acquireUnstableContentProviderClient( - getExternalStorageProviderAuthority())) { - extras.putParcelable(MediaStore.EXTRA_URI, fileUri); - return client.call(method, null, extras); - } catch (RemoteException e) { - throw new IllegalStateException(e); - } + return getResultForGetDocumentUri(method, extras); } case MediaStore.GET_MEDIA_URI_CALL: { - final Uri documentUri = extras.getParcelable(MediaStore.EXTRA_URI); - getContext().enforceCallingUriPermission(documentUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG); - - final int callingPid = mCallingIdentity.get().pid; - final int callingUid = mCallingIdentity.get().uid; - final String callingPackage = getCallingPackage(); - final CallingIdentity token = clearCallingIdentity(); - final String authority = documentUri.getAuthority(); - - if (!authority.equals(MediaDocumentsProvider.AUTHORITY) && - !authority.equals(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { - throw new IllegalArgumentException("Provider for this Uri is not supported."); - } - - try (ContentProviderClient client = getContext().getContentResolver() - .acquireUnstableContentProviderClient(authority)) { - final Bundle clientRes = client.call(method, null, extras); - final Uri fileUri = clientRes.getParcelable(MediaStore.EXTRA_URI); - final Bundle res = new Bundle(); - final Uri mediaStoreUri = fileUri.getAuthority().equals(MediaStore.AUTHORITY) ? - fileUri : queryForMediaUri(new File(fileUri.getPath()), null); - copyUriPermissionGrants(documentUri, mediaStoreUri, callingPid, - callingUid, callingPackage); - res.putParcelable(MediaStore.EXTRA_URI, mediaStoreUri); - return res; - } catch (FileNotFoundException e) { - throw new IllegalArgumentException(e); - } catch (RemoteException e) { - throw new IllegalStateException(e); - } finally { - restoreCallingIdentity(token); - } + return getResultForGetMediaUri(method, extras); } case MediaStore.GET_REDACTED_MEDIA_URI_CALL: { - final Uri uri = extras.getParcelable(MediaStore.EXTRA_URI); - // NOTE: It is ok to update the DB and return a redacted URI for the cases when - // the user code only has read access, hence we don't check for write permission. - enforceCallingPermission(uri, Bundle.EMPTY, false); - final LocalCallingIdentity token = clearLocalCallingIdentity(); - try { - final Bundle res = new Bundle(); - res.putParcelable(MediaStore.EXTRA_URI, getRedactedUri(uri)); - return res; - } finally { - restoreLocalCallingIdentity(token); - } + return getResultForGetRedactedMediaUri(extras); } case MediaStore.GET_REDACTED_MEDIA_URI_LIST_CALL: { - final List<Uri> uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); - // NOTE: It is ok to update the DB and return a redacted URI for the cases when - // the user code only has read access, hence we don't check for write permission. - enforceCallingPermission(uris, false); - final LocalCallingIdentity token = clearLocalCallingIdentity(); - try { - final Bundle res = new Bundle(); - res.putParcelableArrayList(MediaStore.EXTRA_URI_LIST, - (ArrayList<? extends Parcelable>) getRedactedUri(uris)); - return res; - } finally { - restoreLocalCallingIdentity(token); - } + return getResultForGetRedactedMediaUriList(extras); } case MediaStore.GRANT_MEDIA_READ_FOR_PACKAGE_CALL: { - final int caller = Binder.getCallingUid(); - int userId; - final List<Uri> uris; - String packageName; - if (checkPermissionSelf(caller)) { - // If the caller is MediaProvider the accepted parameters are EXTRA_URI_LIST - // and EXTRA_UID. - if (!extras.containsKey( - MediaStore.EXTRA_URI_LIST) - && !extras.containsKey(Intent.EXTRA_UID)) { - throw new IllegalArgumentException( - "Missing required extras arguments: EXTRA_URI_LIST or" - + " EXTRA_UID"); - } - uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); - final PackageManager pm = getContext().getPackageManager(); - final int packageUid = extras.getInt(Intent.EXTRA_UID); - packageName = pm.getNameForUid(packageUid); - // Get the userId from packageUid as the initiator could be a cloned app, which - // accesses Media via MP of its parent user and Binder's callingUid reflects - // the latter. - userId = uidToUserId(packageUid); - if (packageName.contains(":")) { - // Check if the package name includes the package uid. This is expected - // for packages that are referencing a shared user. PackageManager will - // return a string such as <packagename>:<uid> in this instance. - packageName = packageName.split(":")[0]; - } - } else if (checkPermissionShell(caller)) { - // If the caller is the shell, the accepted parameters are EXTRA_URI (as string) - // and EXTRA_PACKAGE_NAME (as string). - if (!extras.containsKey(MediaStore.EXTRA_URI) - && !extras.containsKey(Intent.EXTRA_PACKAGE_NAME)) { - throw new IllegalArgumentException( - "Missing required extras arguments: EXTRA_URI or" - + " EXTRA_PACKAGE_NAME"); - } - packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); - uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); - userId = uidToUserId(caller); - } else { - // All other callers are unauthorized. - throw new SecurityException("Create media grants not allowed. " - + " Calling app ID:" + UserHandle.getAppId(Binder.getCallingUid()) - + " Calling UID:" + Binder.getCallingUid() - + " Media Provider app ID:" + UserHandle.getAppId(MY_UID) - + " Media Provider UID:" + MY_UID); - } - - mMediaGrants.addMediaGrantsForPackage(packageName, uris, userId); - return null; + return getResultForGrantMediaReadForPackage(extras); + } + case MediaStore.REVOKE_READ_GRANT_FOR_PACKAGE_CALL: { + return getResultForRevokeReadGrantForPackage(extras); } case MediaStore.CREATE_WRITE_REQUEST_CALL: case MediaStore.CREATE_FAVORITE_REQUEST_CALL: case MediaStore.CREATE_TRASH_REQUEST_CALL: case MediaStore.CREATE_DELETE_REQUEST_CALL: { - final PendingIntent pi = createRequest(method, extras); - final Bundle res = new Bundle(); - res.putParcelable(MediaStore.EXTRA_RESULT, pi); - return res; + return getResultForCreateOperationsRequest(method, extras); } case MediaStore.IS_SYSTEM_GALLERY_CALL: - final LocalCallingIdentity token = clearLocalCallingIdentity(); - try { - String packageName = arg; - int uid = extras.getInt(MediaStore.EXTRA_IS_SYSTEM_GALLERY_UID); - boolean isSystemGallery = PermissionUtils.checkWriteImagesOrVideoAppOps( - getContext(), uid, packageName, getContext().getAttributionTag()); - Bundle res = new Bundle(); - res.putBoolean(MediaStore.EXTRA_IS_SYSTEM_GALLERY_RESPONSE, isSystemGallery); - return res; - } finally { - restoreLocalCallingIdentity(token); - } + return getResultForIsSystemGallery(arg, extras); + case MediaStore.PICKER_MEDIA_INIT_CALL: { + return getResultForPickerMediaInit(extras); + } case MediaStore.GET_CLOUD_PROVIDER_CALL: { - // TODO(b/245746037): replace UID check with Permission(MANAGE_CLOUD_MEDIA_PROVIDER) - // PhotoPickerSettingsActivity will run as either the primary or the managed user. - // Since the activity shows both personal and work tabs, it will have to make get - // cloud provider IPC call to both instances of Media Provider - one running as - // primary profile and the other as managed profile. Hence, UID check will not be - // feasible here. - if (!checkPermissionSelf(Binder.getCallingUid())) { - throw new SecurityException("Get cloud provider not allowed. " - + " Calling app ID:" + UserHandle.getAppId(Binder.getCallingUid()) - + " Calling UID:" + Binder.getCallingUid() - + " Media Provider app ID:" + UserHandle.getAppId(MY_UID) - + " Media Provider UID:" + MY_UID); - } - final Bundle bundle = new Bundle(); - bundle.putString(MediaStore.GET_CLOUD_PROVIDER_RESULT, - mPickerSyncController.getCloudProvider()); - return bundle; + return getResultForGetCloudProvider(); } case MediaStore.SET_CLOUD_PROVIDER_CALL: { - // TODO(b/267327327): Add permission check before updating cloud provider. Also - // validate the new cloud provider before setting it by using - // PickerSyncController#setCloudProvider instead of - // PickerSyncController#forceSetCloudProvider. - final String cloudProvider = extras.getString(MediaStore.EXTRA_CLOUD_PROVIDER); - Log.i(TAG, "Request received to set cloud provider to " + cloudProvider); - mPickerSyncController.forceSetCloudProvider(cloudProvider); - Log.i(TAG, "Completed request to set cloud provider to " + cloudProvider); - - // Cannot start sync here yet because currently sync and other picker related - // queries like SET_CLOUD_PROVIDER_CALL and GET_CLOUD_PROVIDER use the same lock. - // If we start sync here and then user tries to return to the Picker or change the - // provider again, Picker will ANR and crash. - return new Bundle(); + return getResultForSetCloudProvider(extras); } case MediaStore.SYNC_PROVIDERS_CALL: { - syncAllMedia(); - return new Bundle(); + return getResultForSyncProviders(); } case MediaStore.IS_SUPPORTED_CLOUD_PROVIDER_CALL: { - final boolean isSupported = mPickerSyncController.isProviderSupported(arg, - Binder.getCallingUid()); - - Bundle bundle = new Bundle(); - bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isSupported); - return bundle; + return getResultForIsSupportedCloudProvider(arg); } case MediaStore.IS_CURRENT_CLOUD_PROVIDER_CALL: { - final boolean isEnabled = mPickerSyncController.isProviderEnabled(arg, - Binder.getCallingUid()); - - Bundle bundle = new Bundle(); - bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isEnabled); - return bundle; + return getResultForIsCurrentCloudProviderCall(arg); } case MediaStore.NOTIFY_CLOUD_MEDIA_CHANGED_EVENT_CALL: { - final boolean notifyCloudEventResult; - if (mPickerSyncController.isProviderEnabled(arg, Binder.getCallingUid())) { - mPickerSyncController.notifyMediaEvent(); - notifyCloudEventResult = true; - } else { - notifyCloudEventResult = false; - } - - Bundle bundle = new Bundle(); - bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, - notifyCloudEventResult); - return bundle; + return getResultForNotifyCloudMediaChangedEvent(arg); } case MediaStore.USES_FUSE_PASSTHROUGH: { - boolean isEnabled = false; - try { - FuseDaemon daemon = getFuseDaemonForFile(new File(arg), mVolumeCache); - if (daemon != null) { - isEnabled = daemon.usesFusePassthrough(); - } - } catch (FileNotFoundException e) { - } - - Bundle bundle = new Bundle(); - bundle.putBoolean(MediaStore.USES_FUSE_PASSTHROUGH_RESULT, isEnabled); - return bundle; + return getResultForUsesFusePassThrough(arg); } case MediaStore.RUN_IDLE_MAINTENANCE_FOR_STABLE_URIS: { - backupDatabases(null); - return new Bundle(); + return getResultForIdleMaintenanceForStableUris(); } - case MediaStore.READ_BACKED_UP_FILE_PATHS: { - getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, - "Permission missing to call READ_BACKED_UP_FILE_PATHS by " - + "uid:" + Binder.getCallingUid()); - List<String> cumulatedValues = new ArrayList<String>(); - String[] backedUpFilePaths; - String lastReadValue = ""; - while (true) { - backedUpFilePaths = mDatabaseBackupAndRecovery.readBackedUpFilePaths(arg, - lastReadValue, LEVEL_DB_READ_LIMIT); - if (backedUpFilePaths.length <= 0) { - break; - } - cumulatedValues.addAll(Arrays.asList(backedUpFilePaths)); - if (backedUpFilePaths.length < LEVEL_DB_READ_LIMIT) { - break; - } - lastReadValue = backedUpFilePaths[backedUpFilePaths.length - 1]; - } - - Bundle bundle = new Bundle(); - Object[] values = cumulatedValues.toArray(); - String[] resultArray = Arrays.copyOf(values, values.length, String[].class); - bundle.putStringArray(READ_BACKED_UP_FILE_PATHS, resultArray); - return bundle; + case READ_BACKUP: { + return getResultForReadBackup(arg, extras); } - case MediaStore.DELETE_BACKED_UP_FILE_PATHS: - getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, - "Permission missing to call DELETE_BACKED_UP_FILE_PATHS by " - + "uid:" + Binder.getCallingUid()); - mDatabaseBackupAndRecovery.deleteBackupForVolume(arg); + case GET_OWNER_PACKAGE_NAME: { + return getResultForGetOwnerPackageName(arg); + } + case MediaStore.DELETE_BACKED_UP_FILE_PATHS: { + return getResultForDeleteBackedUpFilePaths(arg); + } + case MediaStore.GET_BACKUP_FILES: { + return getResultForGetBackupFiles(); + } + case MediaStore.GET_RECOVERY_DATA: { + return getResultForGetRecoveryData(); + } + case MediaStore.REMOVE_RECOVERY_DATA: { + removeRecoveryData(); return new Bundle(); - case MediaStore.GET_BACKUP_FILES: - getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, - "Permission missing to call GET_BACKUP_FILES by " - + "uid:" + Binder.getCallingUid()); - List<File> backupFiles = mDatabaseBackupAndRecovery.getBackupFiles(); - List<String> fileNames = new ArrayList<>(); - for (File file : backupFiles) { - fileNames.add(file.getName()); - } - Bundle bundle = new Bundle(); - Object[] values = fileNames.toArray(); - String[] resultArray = Arrays.copyOf(values, values.length, String[].class); - bundle.putStringArray(GET_BACKUP_FILES, resultArray); - return bundle; + } default: throw new UnsupportedOperationException("Unsupported call: " + method); } } + @Nullable + private Bundle getResultForRevokeReadGrantForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + final List<Uri> uris; + String[] packageNames; + if (checkPermissionSelf(caller)) { + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + packageNames = pm.getPackagesForUid(packageUid); + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + } else if (checkPermissionShell(caller)) { + // If the caller is the shell, the accepted parameter is EXTRA_PACKAGE_NAME + // (as string). + if (!extras.containsKey(Intent.EXTRA_PACKAGE_NAME)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI or" + + " EXTRA_PACKAGE_NAME"); + } + packageNames = new String[]{extras.getString(Intent.EXTRA_PACKAGE_NAME)}; + uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); + // Caller is always shell which may not have the desired userId. Hence, use + // UserId from the MediaProvider process itself. + userId = UserHandle.myUserId(); + } else { + // All other callers are unauthorized. + throw new SecurityException( + getSecurityExceptionMessage("read media grants")); + } + + mMediaGrants.removeMediaGrantsForPackage(packageNames, uris, userId); + return null; + } + + @Nullable + private Bundle getResultForResolvePlaylistMembers(Bundle extras) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + try { + final Uri playlistUri = extras.getParcelable(MediaStore.EXTRA_URI); + resolvePlaylistMembers(playlistUri); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return null; + } + + @Nullable + private Bundle getResultForRunIdleMaintenance() { + // Protect ourselves from random apps by requiring a generic + // permission held by common debugging components, such as shell + getContext().enforceCallingOrSelfPermission( + Manifest.permission.DUMP, TAG); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + try { + onIdleMaintenance(new CancellationSignal()); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return null; + } + + @Nullable + private Bundle getResultForWaitForIdle() { + // TODO(b/195009139): Remove after overriding wait for idle in test to sync picker + // Syncing the picker while waiting for idle fixes tests with the picker db + // flag enabled because the picker db is in a consistent state with the external + // db after the sync + syncAllMedia(); + ForegroundThread.waitForIdle(); + final CountDownLatch latch = new CountDownLatch(1); + BackgroundThread.getExecutor().execute(latch::countDown); + try { + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + return null; + } + + @NotNull + private Bundle getResultForScanFile(String arg) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + + final String filePath = arg; + final Uri uri; + try { + File file; + try { + file = FileUtils.getCanonicalFile(filePath); + } catch (IOException e) { + file = null; + } + + uri = file != null ? scanFile(file, REASON_DEMAND) : null; + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + + // TODO(b/262244882): maybe enforceCallingPermissionInternal(uri, ...) + + final Bundle res = new Bundle(); + res.putParcelable(Intent.EXTRA_STREAM, uri); + return res; + } + + private Bundle getResultForScanVolume(String arg) { + final int userId = uidToUserId(Binder.getCallingUid()); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + + final String volumeName = arg; + try { + final MediaVolume volume = mVolumeCache.findVolume(volumeName, + UserHandle.of(userId)); + MediaService.onScanVolume(getContext(), volume, REASON_DEMAND); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find volume " + volumeName, e); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return Bundle.EMPTY; + } + + @NotNull + private Bundle getResultForGetVersion(Bundle extras) { + final String volumeName = extras.getString(Intent.EXTRA_TEXT); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(Files.getContentUri(volumeName)); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + final String version = helper.runWithoutTransaction((db) -> + db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db)); + + final Bundle res = new Bundle(); + res.putString(Intent.EXTRA_TEXT, version); + return res; + } + + @NotNull + private Bundle getResultForGetGeneration(Bundle extras) { + final String volumeName = extras.getString(Intent.EXTRA_TEXT); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(Files.getContentUri(volumeName)); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + final long generation = helper.runWithoutTransaction(DatabaseHelper::getGeneration); + + final Bundle res = new Bundle(); + res.putLong(Intent.EXTRA_INDEX, generation); + return res; + } + + private Bundle getResultForGetDocumentUri(String method, Bundle extras) { + final Uri mediaUri = extras.getParcelable(MediaStore.EXTRA_URI); + enforceCallingPermission(mediaUri, extras, false); + + final Uri fileUri; + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + fileUri = Uri.fromFile(queryForDataFile(mediaUri, null)); + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); + } finally { + restoreLocalCallingIdentity(token); + } + + try (ContentProviderClient client = getContext().getContentResolver() + .acquireUnstableContentProviderClient( + getExternalStorageProviderAuthority())) { + extras.putParcelable(MediaStore.EXTRA_URI, fileUri); + return client.call(method, null, extras); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + @NotNull + private Bundle getResultForGetMediaUri(String method, Bundle extras) { + final Uri documentUri = extras.getParcelable(MediaStore.EXTRA_URI); + getContext().enforceCallingUriPermission(documentUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG); + + final int callingPid = mCallingIdentity.get().pid; + final int callingUid = mCallingIdentity.get().uid; + final String callingPackage = getCallingPackage(); + final CallingIdentity token = clearCallingIdentity(); + final String authority = documentUri.getAuthority(); + + if (!authority.equals(MediaDocumentsProvider.AUTHORITY) + && !authority.equals(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { + throw new IllegalArgumentException("Provider for this Uri is not supported."); + } + + try (ContentProviderClient client = getContext().getContentResolver() + .acquireUnstableContentProviderClient(authority)) { + final Bundle clientRes = client.call(method, null, extras); + final Uri fileUri = clientRes.getParcelable(MediaStore.EXTRA_URI); + final Bundle res = new Bundle(); + final Uri mediaStoreUri = fileUri.getAuthority().equals(MediaStore.AUTHORITY) + ? fileUri : queryForMediaUri(new File(fileUri.getPath()), null); + copyUriPermissionGrants(documentUri, mediaStoreUri, callingPid, + callingUid, callingPackage); + res.putParcelable(MediaStore.EXTRA_URI, mediaStoreUri); + return res; + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } finally { + restoreCallingIdentity(token); + } + } + + @NotNull + private Bundle getResultForGetRedactedMediaUri(Bundle extras) { + final Uri uri = extras.getParcelable(MediaStore.EXTRA_URI); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uri, Bundle.EMPTY, false); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + final Bundle res = new Bundle(); + res.putParcelable(MediaStore.EXTRA_URI, getRedactedUri(uri)); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @NotNull + private Bundle getResultForGetRedactedMediaUriList(Bundle extras) { + final List<Uri> uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uris, false); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + final Bundle res = new Bundle(); + res.putParcelableArrayList(MediaStore.EXTRA_URI_LIST, + (ArrayList<? extends Parcelable>) getRedactedUri(uris)); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Nullable + private Bundle getResultForGrantMediaReadForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + final List<Uri> uris; + String packageName; + if (checkPermissionSelf(caller)) { + // If the caller is MediaProvider the accepted parameters are EXTRA_URI_LIST + // and EXTRA_UID. + if (!extras.containsKey(MediaStore.EXTRA_URI_LIST) + && !extras.containsKey(Intent.EXTRA_UID)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI_LIST or" + " EXTRA_UID"); + } + uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + final String[] packages = pm.getPackagesForUid(packageUid); + if (packages == null || packages.length == 0) { + throw new IllegalArgumentException( + String.format( + "Could not find packages for media_grants with uid: %d", + packageUid)); + } + // Use the first package in the returned list for grants. In the case this + // uid has multiple shared packages, the eventual queries to check for file + // access will use all of the packages in this list, so just one is needed + // to create the grants. + packageName = packages[0]; + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + } else if (checkPermissionShell(caller)) { + // If the caller is the shell, the accepted parameters are EXTRA_URI (as string) + // and EXTRA_PACKAGE_NAME (as string). + if (!extras.containsKey(MediaStore.EXTRA_URI) + && !extras.containsKey(Intent.EXTRA_PACKAGE_NAME)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI or" + " EXTRA_PACKAGE_NAME"); + } + packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); + uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); + // Caller is always shell which may not have the desired userId. Hence, use + // UserId from the MediaProvider process itself. + userId = UserHandle.myUserId(); + } else { + // All other callers are unauthorized. + + throw new SecurityException(getSecurityExceptionMessage("Create media grants")); + } + + mMediaGrants.addMediaGrantsForPackage(packageName, uris, userId); + return null; + } + + @NotNull + private Bundle getResultForCreateOperationsRequest(String method, Bundle extras) { + final PendingIntent pi = createRequest(method, extras); + final Bundle res = new Bundle(); + res.putParcelable(MediaStore.EXTRA_RESULT, pi); + return res; + } + + @NotNull + private Bundle getResultForIsSystemGallery(String arg, Bundle extras) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + String packageName = arg; + int uid = extras.getInt(MediaStore.EXTRA_IS_SYSTEM_GALLERY_UID); + boolean isSystemGallery = PermissionUtils.checkWriteImagesOrVideoAppOps( + getContext(), uid, packageName, getContext().getAttributionTag()); + Bundle res = new Bundle(); + res.putBoolean(MediaStore.EXTRA_IS_SYSTEM_GALLERY_RESPONSE, isSystemGallery); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Nullable + private Bundle getResultForPickerMediaInit(Bundle extras) { + Log.i(TAG, "Received media init query for extras: " + extras); + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid())) { + throw new SecurityException( + getSecurityExceptionMessage("Picker media init")); + } + mPickerDataLayer.initMediaData(PickerSyncRequestExtras.fromBundle(extras)); + return null; + } + + @NotNull + private Bundle getResultForGetCloudProvider() { + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid())) { + throw new SecurityException( + getSecurityExceptionMessage("Get cloud provider")); + } + final Bundle bundle = new Bundle(); + bundle.putString(MediaStore.GET_CLOUD_PROVIDER_RESULT, + mPickerSyncController.getCloudProvider()); + return bundle; + } + + @NotNull + private Bundle getResultForSetCloudProvider(Bundle extras) { + final String cloudProvider = extras.getString(MediaStore.EXTRA_CLOUD_PROVIDER); + Log.i(TAG, "Request received to set cloud provider to " + cloudProvider); + boolean isUpdateSuccessful = false; + if (checkPermissionSelf(Binder.getCallingUid())) { + isUpdateSuccessful = mPickerSyncController.setCloudProvider(cloudProvider); + } else if (checkPermissionShell(Binder.getCallingUid())) { + isUpdateSuccessful = + mPickerSyncController.forceSetCloudProvider(cloudProvider); + } else { + throw new SecurityException( + getSecurityExceptionMessage("Set cloud provider")); + } + + if (isUpdateSuccessful) { + Log.i(TAG, "Completed request to set cloud provider to " + cloudProvider); + } + final Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.SET_CLOUD_PROVIDER_RESULT, isUpdateSuccessful); + return bundle; + } + + @NotNull + private Bundle getResultForSyncProviders() { + syncAllMedia(); + return new Bundle(); + } + + @NotNull + private Bundle getResultForIsSupportedCloudProvider(String arg) { + final boolean isSupported = mPickerSyncController.isProviderSupported(arg, + Binder.getCallingUid()); + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isSupported); + return bundle; + } + + @NotNull + private Bundle getResultForIsCurrentCloudProviderCall(String arg) { + Bundle bundle = new Bundle(); + boolean isEnabled = false; + + if (mConfigStore.isCloudMediaInPhotoPickerEnabled()) { + isEnabled = + mPickerSyncController.isProviderEnabled( + arg, Binder.getCallingUid()); + } + + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isEnabled); + return bundle; + } + + @NotNull + private Bundle getResultForNotifyCloudMediaChangedEvent(String arg) { + final boolean notifyCloudEventResult; + if (mPickerSyncController.isProviderEnabled(arg, Binder.getCallingUid())) { + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ false); + notifyCloudEventResult = true; + } else { + notifyCloudEventResult = false; + } + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, + notifyCloudEventResult); + return bundle; + } + + @NotNull + private Bundle getResultForUsesFusePassThrough(String arg) { + boolean isEnabled = false; + try { + FuseDaemon daemon = getFuseDaemonForFile(new File(arg), mVolumeCache); + if (daemon != null) { + isEnabled = daemon.usesFusePassthrough(); + } + } catch (FileNotFoundException e) { + } + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.USES_FUSE_PASSTHROUGH_RESULT, isEnabled); + return bundle; + } + + @NotNull + private Bundle getResultForIdleMaintenanceForStableUris() { + backupDatabases(null); + return new Bundle(); + } + + @NotNull + private Bundle getResultForReadBackup(String arg, Bundle extras) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call READ_BACKUP by uid:" + Binder.getCallingUid()); + Bundle bundle = new Bundle(); + Optional<BackupIdRow> backupIdRowOptional = + mDatabaseBackupAndRecovery.readDataFromBackup(arg, extras.getString( + FileColumns.DATA)); + String data = null; + try { + data = backupIdRowOptional.isPresent() ? BackupIdRow.serialize( + backupIdRowOptional.get()) : null; + } catch (IOException e) { + throw new RuntimeException(e); + } + bundle.putString(READ_BACKUP, data); + return bundle; + } + + @NotNull + private Bundle getResultForGetOwnerPackageName(String arg) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_OWNER_PACKAGE_NAME by " + + "uid:" + Binder.getCallingUid()); + try { + String ownerPackageName = mDatabaseBackupAndRecovery.readOwnerPackageName(arg); + Bundle result = new Bundle(); + result.putString(GET_OWNER_PACKAGE_NAME, ownerPackageName); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @NotNull + private Bundle getResultForDeleteBackedUpFilePaths(String arg) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call DELETE_BACKED_UP_FILE_PATHS by " + + "uid:" + Binder.getCallingUid()); + mDatabaseBackupAndRecovery.deleteBackupForVolume(arg); + return new Bundle(); + } + + @NotNull + private Bundle getResultForGetBackupFiles() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_BACKUP_FILES by " + + "uid:" + Binder.getCallingUid()); + List<File> backupFiles = mDatabaseBackupAndRecovery.getBackupFiles(); + List<String> fileNames = new ArrayList<>(); + for (File file : backupFiles) { + fileNames.add(file.getName()); + } + Bundle bundle = new Bundle(); + Object[] values = fileNames.toArray(); + String[] resultArray = Arrays.copyOf(values, values.length, String[].class); + bundle.putStringArray(GET_BACKUP_FILES, resultArray); + return bundle; + } + + @NotNull + private Bundle getResultForGetRecoveryData() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_RECOVERY_DATA by " + + "uid:" + Binder.getCallingUid()); + + String[] xattrs = null; + try { + xattrs = Os.listxattr("/data/media/0"); + } catch (ErrnoException e) { + Log.w(TAG, "Error in getting xattr list ", e); + } + + Bundle bundle = new Bundle(); + bundle.putStringArray(MediaStore.GET_RECOVERY_DATA, xattrs); + return bundle; + } + + private void removeRecoveryData() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call REMOVE_RECOVERY_DATA by " + + "uid:" + Binder.getCallingUid()); + + List<String> validUsers = mUserManager.getUserHandles(/* excludeDying */ true).stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); + Log.i(TAG, "Active user ids are:" + validUsers); + mDatabaseBackupAndRecovery.removeRecoveryDataExceptValidUsers(validUsers); + } + + private String getSecurityExceptionMessage(String method) { + int callingUid = Binder.getCallingUid(); + return String.format("%s not allowed. Calling app ID: %d, Calling UID %d. " + + "Media Provider app ID: %d, Media Provider UID: %d.", + method, + UserHandle.getAppId(callingUid), + callingUid, + UserHandle.getAppId(MY_UID), + MY_UID); + } + public void backupDatabases(CancellationSignal signal) { mDatabaseBackupAndRecovery.backupDatabases(mInternalDatabase, mExternalDatabase, signal); } @@ -6980,8 +7434,13 @@ public class MediaProvider extends ContentProvider { final Context context = getContext(); final Intent intent = new Intent(method, null, context, PermissionActivity.class); intent.putExtras(extras); + final ActivityOptions options = ActivityOptions.makeBasic(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + options.setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); + } return PendingIntent.getActivity(context, PermissionActivity.REQUEST_CODE, intent, - FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); + FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE, options.toBundle()); } /** @@ -7260,7 +7719,7 @@ public class MediaProvider extends ContentProvider { @Override public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable Bundle extras) { - Trace.beginSection("MP.update [" + uri + ']'); + Trace.beginSection(safeTraceSectionNameWithUri("update", uri)); try { return updateInternal(uri, values, extras); } catch (FallbackException e) { @@ -7310,7 +7769,6 @@ public class MediaProvider extends ContentProvider { int count; - final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); final DatabaseHelper helper = getDatabaseForUri(uri); @@ -7583,7 +8041,7 @@ public class MediaProvider extends ContentProvider { final String probePath = initialValues.getAsString(MediaColumns.DATA); final String probeVolume = extractVolumeName(probePath); final String probeOwner = extractPathOwnerPackageName(probePath); - if (Objects.equals(beforePath, probePath)) { + if (StringUtils.equalIgnoreCase(beforePath, probePath)) { Log.d(TAG, "Identical paths " + beforePath + "; not moving"); } else if (!Objects.equals(beforeVolume, probeVolume)) { throw new IllegalArgumentException("Changing volume from " + beforePath + " to " @@ -9688,12 +10146,21 @@ public class MediaProvider extends ContentProvider { values.put(MediaColumns.MIME_TYPE, mimeType); values.put(FileColumns.IS_PENDING, 1); + int userIdFromPath = FileUtils.extractUserId(path); + if (useData) { values.put(FileColumns.DATA, path); } else { values.put(FileColumns.VOLUME_NAME, extractVolumeName(path)); values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path)); values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path)); + // In some cases when clone profile is active, this userId can be used to determine + // the path to be saved in MP database. + // We do this only if the path contains a valid user-id and any such value set is + // only a hint, the actual userId set will be determined later. + if (userIdFromPath != -1) { + values.put(FileColumns._USER_ID, userIdFromPath); + } } return insert(uri, values, Bundle.EMPTY); } @@ -10565,7 +11032,8 @@ public class MediaProvider extends ContentProvider { return MediaStore.AUTHORITY_URI.buildUpon().appendPath(volumeName).build(); } - public Uri attachVolume(MediaVolume volume, boolean validate) { + public Uri attachVolume(MediaVolume volume, boolean validate, String volumeState) { + Log.v(TAG, "attachVolume() called for " + volume.getName() + " with state:" + volumeState); if (mCallingIdentity.get().pid != android.os.Process.myPid()) { throw new SecurityException( "Opening and closing databases not allowed."); @@ -10590,9 +11058,6 @@ public class MediaProvider extends ContentProvider { mAttachedVolumes.add(volume); } - mDatabaseBackupAndRecovery.setupVolumeDbBackupAndRecovery(volume.getName(), - volume.getPath()); - final ContentResolver resolver = getContext().getContentResolver(); final Uri uri = getBaseContentUri(volumeName); // TODO(b/182396009) we probably also want to notify clone profile (and vice versa) @@ -10605,8 +11070,7 @@ public class MediaProvider extends ContentProvider { ForegroundThread.getExecutor().execute(() -> { mExternalDatabase.runWithTransaction((db) -> { - ensureDefaultFolders(volume, db); - ensureThumbnailsValid(volume, db); + ensureNecessaryFolders(volume, db); return null; }); @@ -10616,6 +11080,12 @@ public class MediaProvider extends ContentProvider { MediaDocumentsProvider.onMediaStoreReady(getContext()); }); } + + if (Environment.MEDIA_MOUNTED.equalsIgnoreCase(volumeState)) { + mDatabaseBackupAndRecovery.setupVolumeDbBackupAndRecovery(volume.getName(), + volume.getPath()); + } + return uri; } @@ -10668,6 +11138,22 @@ public class MediaProvider extends ContentProvider { if (LOGV) Log.v(TAG, "Detached volume: " + volumeName); } + private void ensureNecessaryFolders(MediaVolume volume, SQLiteDatabase db) { + ensureDefaultFolders(volume, db); + ensureThumbnailsValid(volume, db); + + // Create redacted directories + if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volume.getName())) { + // Create dir for redacted and picker URI paths. + File redactedRelativePath = buildPrimaryVolumeFile(uidToUserId(MY_UID), + getRedactedRelativePath()); + if (!redactedRelativePath.exists() && !redactedRelativePath.mkdirs()) { + // We should always be able to create these directories from MediaProvider + Log.wtf(TAG, "Couldn't create redacted path for " + UserHandle.myUserId()); + } + } + } + @GuardedBy("mAttachedVolumes") private final ArraySet<MediaVolume> mAttachedVolumes = new ArraySet<>(); @GuardedBy("mCustomCollators") @@ -10942,6 +11428,15 @@ public class MediaProvider extends ContentProvider { mTranscodeHelper.dump(writer); writer.println(); + mConfigStore.dump(writer); + writer.println(); + + mPickerDbFacade.dump(writer); + writer.println(); + + mPickerSyncController.dump(writer); + writer.println(); + dumpAccessLogs(writer); writer.println(); diff --git a/src/com/android/providers/media/MediaProviderShellCommand.java b/src/com/android/providers/media/MediaProviderShellCommand.java index ddeef000c..38861604e 100644 --- a/src/com/android/providers/media/MediaProviderShellCommand.java +++ b/src/com/android/providers/media/MediaProviderShellCommand.java @@ -34,6 +34,7 @@ import com.android.modules.utils.HandlerExecutor; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.CloudProviderInfo; import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; import java.io.OutputStream; import java.io.PrintWriter; @@ -187,7 +188,12 @@ class MediaProviderShellCommand extends BasicShellCommandHandler { // TODO(b/242550131): add PickerSyncController's API to make it possible to reset just one // provider's library at a time (i.e. either CMP or local). - mPickerSyncController.resetAllMedia(); + try { + mPickerSyncController.resetAllMedia(); + } catch (UnableToAcquireLockException e) { + pw.print("Could not reset all media" + e.getMessage()); + return 1; + } pw.println("Done."); return 0; diff --git a/src/com/android/providers/media/MediaService.java b/src/com/android/providers/media/MediaService.java index 690114d67..37f9b028a 100644 --- a/src/com/android/providers/media/MediaService.java +++ b/src/com/android/providers/media/MediaService.java @@ -186,7 +186,7 @@ public class MediaService extends JobIntentService { try (ContentProviderClient cpc = context.getContentResolver() .acquireContentProviderClient(MediaStore.AUTHORITY)) { final MediaProvider provider = ((MediaProvider) cpc.getLocalContentProvider()); - provider.attachVolume(volume, /* validate */ true); + provider.attachVolume(volume, /* validate */ true, /* volumeState */ null); final ContentResolver resolver = ContentResolver.wrap(cpc.getLocalContentProvider()); diff --git a/src/com/android/providers/media/PermissionActivity.java b/src/com/android/providers/media/PermissionActivity.java index d54841b7d..ad925cac1 100644 --- a/src/com/android/providers/media/PermissionActivity.java +++ b/src/com/android/providers/media/PermissionActivity.java @@ -243,10 +243,6 @@ public class PermissionActivity extends Activity { Log.w(TAG, "Couldn't find message element"); } - final WindowManager.LayoutParams params = actionDialog.getWindow().getAttributes(); - params.width = getResources().getDimensionPixelSize(R.dimen.permission_dialog_width); - actionDialog.getWindow().setAttributes(params); - // Hunt around to find the title of our newly created dialog so we can // adjust accessibility focus once descriptions have been loaded titleView = (TextView) findViewByPredicate(actionDialog.getWindow().getDecorView(), @@ -640,7 +636,7 @@ public class PermissionActivity extends Activity { private @Nullable CharSequence resolveTitleText() { final String resName = "permission_" + verb + "_" + data; final int resId = getResources().getIdentifier(resName, "string", - getResources().getResourcePackageName(R.string.app_label)); + getResources().getResourcePackageName(R.string.picker_app_label)); if (resId != 0) { final int count = uris.size(); final CharSequence text = StringUtils.getICUFormatString(getResources(), count, resId); @@ -658,7 +654,7 @@ public class PermissionActivity extends Activity { private @Nullable CharSequence resolveProgressMessageText() { final String resName = "permission_progress_" + verb + "_" + data; final int resId = getResources().getIdentifier(resName, "string", - getResources().getResourcePackageName(R.string.app_label)); + getResources().getResourcePackageName(R.string.picker_app_label)); if (resId != 0) { final int count = uris.size(); final CharSequence text = StringUtils.getICUFormatString(getResources(), count, resId); diff --git a/src/com/android/providers/media/PickerUriResolver.java b/src/com/android/providers/media/PickerUriResolver.java index 3625b5d51..b56b6941a 100644 --- a/src/com/android/providers/media/PickerUriResolver.java +++ b/src/com/android/providers/media/PickerUriResolver.java @@ -44,7 +44,7 @@ import androidx.annotation.VisibleForTesting; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.photopicker.data.PickerDbFacade; import com.android.providers.media.photopicker.data.model.UserId; -import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; +import com.android.providers.media.photopicker.metrics.NonUiEventLogger; import java.io.File; import java.io.FileNotFoundException; @@ -70,6 +70,10 @@ public class PickerUriResolver { public static final Uri PICKER_INTERNAL_URI = MediaStore.AUTHORITY_URI.buildUpon(). appendPath(PICKER_INTERNAL_SEGMENT).build(); + public static final String REFRESH_PICKER_UI_PATH = "refresh_ui"; + public static final Uri REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI = + PICKER_INTERNAL_URI.buildUpon().appendPath(REFRESH_PICKER_UI_PATH).build(); + public static final String MEDIA_PATH = "media"; public static final String ALBUM_PATH = "albums"; @@ -314,8 +318,9 @@ public class PickerUriResolver { for (String column : projection) { if (!mAllValidProjectionColumns.contains(column)) { - final PhotoPickerUiEventLogger logger = new PhotoPickerUiEventLogger(); - logger.logPickerQueriedWithUnknownColumn(callingUid, callingPackageName); + final String callingPackageAndColumn = callingPackageName + ":" + column; + NonUiEventLogger.logPickerQueriedWithUnknownColumn( + callingUid, callingPackageAndColumn); } } } diff --git a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java index 84f4205e3..a28420c84 100644 --- a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java +++ b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java @@ -107,7 +107,7 @@ public final class ExternalStorageServiceImpl extends ExternalStorageService { switch(vol.getState()) { case Environment.MEDIA_MOUNTED: MediaVolume volume = MediaVolume.fromStorageVolume(vol); - mediaProvider.attachVolume(volume, /* validate */ false); + mediaProvider.attachVolume(volume, /* validate */ false, Environment.MEDIA_MOUNTED); MediaService.queueVolumeScan(mediaProvider.getContext(), volume, REASON_MOUNTED); break; case Environment.MEDIA_UNMOUNTED: diff --git a/src/com/android/providers/media/fuse/FuseDaemon.java b/src/com/android/providers/media/fuse/FuseDaemon.java index 34836af41..fa910c195 100644 --- a/src/com/android/providers/media/fuse/FuseDaemon.java +++ b/src/com/android/providers/media/fuse/FuseDaemon.java @@ -36,8 +36,8 @@ import java.util.Objects; */ public final class FuseDaemon extends Thread { public static final String TAG = "FuseDaemonThread"; - private static final int POLL_INTERVAL_MS = 1000; - private static final int POLL_COUNT = 5; + private static final int POLL_INTERVAL_MS = 100; + private static final int POLL_COUNT = 50; private final Object mLock = new Object(); private final MediaProvider mMediaProvider; @@ -219,6 +219,18 @@ public final class FuseDaemon extends Thread { } /** + * Sets up public volume's database backup to external storage to recover during a rollback. + */ + public void setupPublicVolumeDbBackup(String volumeName) throws IOException { + synchronized (mLock) { + if (mPtr == 0) { + throw new IOException("FUSE daemon unavailable"); + } + native_setup_public_volume_db_backup(mPtr, volumeName); + } + } + + /** * Deletes entry for given key from external storage. */ public void deleteDbBackup(String key) throws IOException { @@ -231,14 +243,14 @@ public final class FuseDaemon extends Thread { } /** - * Backs up given key-value pair in external storage. + * Backs up given key-value pair in external storage for provided volume. */ - public void backupVolumeDbData(String key, String value) throws IOException { + public void backupVolumeDbData(String volumeName, String key, String value) throws IOException { synchronized (mLock) { if (mPtr == 0) { throw new IOException("FUSE daemon unavailable"); } - native_backup_volume_db_data(mPtr, key, value); + native_backup_volume_db_data(mPtr, volumeName, key, value); } } @@ -333,8 +345,10 @@ public final class FuseDaemon extends Thread { private native FdAccessResult native_check_fd_access(long daemon, int fd, int uid); private native void native_initialize_device_id(long daemon, String path); private native void native_setup_volume_db_backup(long daemon); + private native void native_setup_public_volume_db_backup(long daemon, String volumeName); private native void native_delete_db_backup(long daemon, String key); - private native void native_backup_volume_db_data(long daemon, String key, String value); + private native void native_backup_volume_db_data(long daemon, String volumeName, String key, + String value); private native String[] native_read_backed_up_file_paths(long daemon, String volumeName, String lastReadValue, int limit); private native String native_read_backed_up_data(long daemon, String key); diff --git a/src/com/android/providers/media/photopicker/DataLoaderThread.java b/src/com/android/providers/media/photopicker/DataLoaderThread.java new file mode 100644 index 000000000..1101b6a73 --- /dev/null +++ b/src/com/android/providers/media/photopicker/DataLoaderThread.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2023 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 android.os.Handler; +import android.os.HandlerThread; + +import com.android.modules.utils.HandlerExecutor; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +/** + * Thread for asynchronous event processing. This thread is configured as + * {@link android.os.Process#THREAD_PRIORITY_FOREGROUND}, which means more CPU + * resources will be dedicated to it, and it will be treated like "a user + * interface that the user is interacting with." + * <p> + * This thread is best suited for UI related tasks that the user is actively waiting for. + * (like data loading on grid, banner initialization etc.) + * + */ +public class DataLoaderThread extends HandlerThread { + private static DataLoaderThread sInstance; + private static Handler sHandler; + private static HandlerExecutor sHandlerExecutor; + + // Token for cancelling tasks in handler's queue. Can be used with Handler#postDelayed. + public static Object TOKEN = new Object(); + + public DataLoaderThread() { + super("DataLoaderThread", android.os.Process.THREAD_PRIORITY_FOREGROUND); + } + + private static void ensureThreadLocked() { + if (sInstance == null) { + sInstance = new DataLoaderThread(); + sInstance.start(); + sHandler = new Handler(sInstance.getLooper()); + sHandlerExecutor = new HandlerExecutor(sHandler); + } + } + + /** + * Return singleton instance of DataLoaderThread. + */ + public static DataLoaderThread get() { + synchronized (DataLoaderThread.class) { + ensureThreadLocked(); + return sInstance; + } + } + + /** + * Return singleton handler of DataLoaderThread. + */ + public static Handler getHandler() { + synchronized (DataLoaderThread.class) { + ensureThreadLocked(); + return sHandler; + } + } + + /** + * Return singleton executor of DataLoaderThread. + */ + public static Executor getExecutor() { + synchronized (DataLoaderThread.class) { + ensureThreadLocked(); + return sHandlerExecutor; + } + } + + /** + * Wait for thread to be idle. + */ + public static void waitForIdle() { + final CountDownLatch latch = new CountDownLatch(1); + getExecutor().execute(() -> { + latch.countDown(); + }); + try { + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/com/android/providers/media/photopicker/DialogUtils.java b/src/com/android/providers/media/photopicker/DialogUtils.java new file mode 100644 index 000000000..9f94aeff5 --- /dev/null +++ b/src/com/android/providers/media/photopicker/DialogUtils.java @@ -0,0 +1,57 @@ +/* + * 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 android.app.AlertDialog; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import com.android.providers.media.R; + +/** + * Dialog box to display custom alert or error messages + */ +public class DialogUtils extends AppCompatActivity { + /** + * Custom dialog box with single button to display title and single error message + */ + public static void showDialog(Context context, String title, String message) { + View customView = + LayoutInflater.from(context).inflate(R.layout.error_dialog, null); + + TextView dialogTitle = customView.findViewById(R.id.title); + TextView dialogMessage = customView.findViewById(R.id.message); + Button gotItButton = customView.findViewById(R.id.okButton); + dialogTitle.setText(title); + dialogMessage.setText(message); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setView(customView); + builder.setCancelable(false); // Prevent dismiss when clicking outside + final AlertDialog dialog = builder.create(); + + gotItButton.setOnClickListener(v -> { + dialog.dismiss(); // Close the dialog + }); + dialog.show(); + } +} diff --git a/src/com/android/providers/media/photopicker/NotificationContentObserver.java b/src/com/android/providers/media/photopicker/NotificationContentObserver.java new file mode 100644 index 000000000..c20500289 --- /dev/null +++ b/src/com/android/providers/media/photopicker/NotificationContentObserver.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker; + +import static com.android.providers.media.PickerUriResolver.PICKER_INTERNAL_URI; + +import android.content.ContentResolver; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Handler; +import android.util.Log; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * {@link ContentObserver} to listen to notification on database update + * (for e.g. cloud sync completion of a batch). + * + * <p> This observer listens to below uris: + * <ul> + * <li>content://media/picker_internal/update</li> + * <li>content://media/picker_internal/update/media</li> + * <li>content://media/picker_internal/update/album_content/ALBUM_ID</li> + * </ul> + * + * <p> The notification received will contain date_taken_ms + * {@link android.provider.CloudMediaProviderContract.MediaColumns#DATE_TAKEN_MILLIS} or + * {@link android.provider.CloudMediaProviderContract.AlbumColumns#DATE_TAKEN_MILLIS}. + * In case of album content, it will also contain + * {@link android.provider.CloudMediaProviderContract#EXTRA_ALBUM_ID} + */ +public class NotificationContentObserver extends ContentObserver { + private static final String TAG = "NotificationContentObserver"; + + /** + * Callback triggered upon receiving notification. + */ + public interface ContentObserverCallback{ + /** + * Callers must implement this to handle the notification received. + * + * @param dateTakenMs date_taken_ms of the update + * @param albumId album_id in case of album_content update. Null in case of media update + */ + void onNotificationReceived(String dateTakenMs, String albumId); + } + + // Key: Collection of preference keys, Value: onChange callback for keys + private final Map<List<String>, ContentObserverCallback> mUrisToCallback = new HashMap<>(); + + public static final String UPDATE = "update"; + public static final String MEDIA = "media"; + public static final String ALBUM_CONTENT = "album_content"; + + private final List<String> mKeys; + private final List<Uri> mUris; + + private static final Uri URI_UPDATE = PICKER_INTERNAL_URI.buildUpon() + .appendPath(UPDATE).build(); + + private static final Uri URI_UPDATE_MEDIA = URI_UPDATE.buildUpon() + .appendPath(MEDIA).build(); + + private static final Uri URI_UPDATE_ALBUM_CONTENT = URI_UPDATE.buildUpon() + .appendPath(ALBUM_CONTENT).build(); + + public static final String REGEX_MEDIA = URI_UPDATE_MEDIA + "/[0-9]*$"; + public static final Pattern PATTERN_MEDIA = Pattern.compile(REGEX_MEDIA); + public static final String REGEX_ALBUM_CONTENT = URI_UPDATE_ALBUM_CONTENT + "/[0-9]*/[0-9]*$"; + public static final Pattern PATTERN_ALBUM_CONTENT = Pattern.compile(REGEX_ALBUM_CONTENT); + + /** + * Creates a content observer. + * + * @param handler The handler to run {@link #onChange} on, or null if none. + */ + public NotificationContentObserver(Handler handler) { + super(handler); + mKeys = Arrays.asList(MEDIA, ALBUM_CONTENT); + mUris = Arrays.asList(URI_UPDATE_MEDIA, URI_UPDATE_ALBUM_CONTENT); + } + + /** + * Registers {@link ContentObserver} instance of this class to the resolver for {@link #mUris}. + */ + public void register(ContentResolver contentResolver) { + for (Uri uri : mUris) { + contentResolver.registerContentObserver(uri, /* notifyForDescendants */ true, + /* observer */ this); + } + } + + /** + * Unregisters ContentObserver + */ + public void unregister(ContentResolver contentResolver) { + contentResolver.unregisterContentObserver(this); + } + + /** + * {@link ContentObserverCallback} is added to {@link ContentObserver} to handle the + * onNotificationReceived event triggered by the key collection of {@code keysToObserve}. + * + * <p> Note: Observer can observe the keys present in {@link #mKeys}. + * + * @param observerCallback A callback which is used to handle the onNotificationReceived event + * triggered by the key collection of {@code keysToObserve}. + */ + public void registerKeysToObserverCallback(List<String> keysToObserve, + ContentObserverCallback observerCallback) { + boolean hasValidKey = false; + for (String key : keysToObserve) { + if (!mKeys.contains(key)) { + Log.w(TAG, "NotificationContentObserver can not observer the key: " + key + + ". Please pass valid keys from " + mKeys); + continue; + } + hasValidKey = true; + } + if (hasValidKey) { + mUrisToCallback.put(keysToObserve, observerCallback); + } + } + + @Override + public final void onChange(boolean selfChange, Uri uri) { + String albumId = null; + String key = null; + + if (PATTERN_MEDIA.matcher(uri.toString()).find()) { + key = MEDIA; + } else if (PATTERN_ALBUM_CONTENT.matcher(uri.toString()).find()) { + key = ALBUM_CONTENT; + albumId = uri.getPathSegments().get(3); + } else { + Log.w(TAG, "NotificationContentObserver cannot parse uri: " + uri + + " . Please send correct uri path."); + return; + } + + String dateTakenMs = uri.getLastPathSegment(); + + for (List<String> keys : mUrisToCallback.keySet()) { + if (keys.contains(key)) { + mUrisToCallback.get(keys).onNotificationReceived(dateTakenMs, albumId); + } + } + } + + @VisibleForTesting + public Map<List<String>, ContentObserverCallback> getUrisToCallback() { + return mUrisToCallback; + } +} diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java index 7f748c610..48ee7fbaf 100644 --- a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java +++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java @@ -21,7 +21,6 @@ import static android.provider.MediaStore.ACTION_PICK_IMAGES; import static android.provider.MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP; import static android.provider.MediaStore.grantMediaReadForPackage; -import static com.android.providers.media.MediaApplication.getConfigStore; import static com.android.providers.media.photopicker.PhotoPickerSettingsActivity.EXTRA_CURRENT_USER_ID; import static com.android.providers.media.photopicker.data.PickerResult.getPickerResponseIntent; import static com.android.providers.media.photopicker.data.PickerResult.getPickerUrisForItems; @@ -47,6 +46,7 @@ import android.os.Binder; import android.os.Build; import android.os.Bundle; import android.os.UserHandle; +import android.provider.MediaStore; import android.util.Log; import android.util.TypedValue; import android.view.Menu; @@ -66,6 +66,8 @@ import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -74,6 +76,7 @@ import com.android.providers.media.R; import com.android.providers.media.photopicker.data.PickerResult; import com.android.providers.media.photopicker.data.Selection; 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 com.android.providers.media.photopicker.ui.TabContainerFragment; import com.android.providers.media.photopicker.util.LayoutModeUtils; @@ -116,6 +119,10 @@ public class PhotoPickerActivity extends AppCompatActivity { private Toolbar mToolbar; private CrossProfileListeners mCrossProfileListeners; + @NonNull + private final MutableLiveData<Boolean> mIsItemPhotoGridViewChanged = + new MutableLiveData<>(false); + @ColorInt private int mDefaultBackgroundColor; @@ -123,7 +130,6 @@ public class PhotoPickerActivity extends AppCompatActivity { private int mToolBarIconColor; private int mToolbarHeight = 0; - private boolean mIsAccessibilityEnabled; private boolean mShouldLogCancelledResult = true; @Override @@ -147,7 +153,6 @@ public class PhotoPickerActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_photo_picker); - mToolbar = findViewById(R.id.toolbar); setSupportActionBar(mToolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -173,7 +178,6 @@ public class PhotoPickerActivity extends AppCompatActivity { return; } mSelection = mPickerViewModel.getSelection(); - mDragBar = findViewById(R.id.drag_bar); mPrivacyText = findViewById(R.id.privacy_text); mBottomBar = findViewById(R.id.picker_bottom_bar); @@ -181,16 +185,7 @@ public class PhotoPickerActivity extends AppCompatActivity { mTabLayout = findViewById(R.id.tab_layout); - final AccessibilityManager am = getSystemService(AccessibilityManager.class); - mIsAccessibilityEnabled = am.isEnabled(); - am.addAccessibilityStateChangeListener(enabled -> mIsAccessibilityEnabled = enabled); - initBottomSheetBehavior(); - restoreState(savedInstanceState); - - final String intentAction = intent != null ? intent.getAction() : null; - // Call this after state is restored, to use the correct LOGGER_INSTANCE_ID_ARG - mPickerViewModel.logPickerOpened(Binder.getCallingUid(), getCallingPackage(), intentAction); // Save the fragment container layout so that we can adjust the padding based on preview or // non-preview mode. @@ -202,6 +197,16 @@ public class PhotoPickerActivity extends AppCompatActivity { if (mPreloaderInstanceHolder.preloader != null) { subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader); } + + observeRefreshUiNotificationLiveData(); + // Restore state operation should always be kept at the end of this method. + restoreState(savedInstanceState); + // Call this after state is restored, to use the correct LOGGER_INSTANCE_ID_ARG + if (savedInstanceState == null) { + final String intentAction = intent != null ? intent.getAction() : null; + mPickerViewModel.logPickerOpened(Binder.getCallingUid(), getCallingPackage(), + intentAction); + } } @Override @@ -239,13 +244,33 @@ public class PhotoPickerActivity extends AppCompatActivity { return super.dispatchTouchEvent(event); } + /** + * This method is called on action bar home button clicks if + * {@link androidx.appcompat.app.ActionBar#setDisplayHomeAsUpEnabled(boolean)} is set + * {@code true}. + */ @Override public boolean onSupportNavigateUp() { - onBackPressed(); + int backStackEntryCount = getSupportFragmentManager().getBackStackEntryCount(); + mPickerViewModel.logActionBarHomeButtonClick(backStackEntryCount); + super.onBackPressed(); return true; } @Override + public void onBackPressed() { + int backStackEntryCount = getSupportFragmentManager().getBackStackEntryCount(); + mPickerViewModel.logBackGestureWithStackCount(backStackEntryCount); + super.onBackPressed(); + } + + @Override + public boolean onMenuOpened(int featureId, Menu menu) { + mPickerViewModel.logMenuOpened(); + return super.onMenuOpened(featureId, menu); + } + + @Override public void setTitle(CharSequence title) { super.setTitle(title); getSupportActionBar().setTitle(title); @@ -312,19 +337,6 @@ public class PhotoPickerActivity extends AppCompatActivity { startActivity(intent); } - @Override - public void onRestart() { - super.onRestart(); - - // TODO(b/262001857): For each profile, conditionally reset PhotoPicker when cloud provider - // app or account has changed. Currently, we'll reset picker each time it restarts when - // settings page is enabled to avoid the scenario where cloud provider app or account has - // changed but picker continues to show stale data from old provider app and account. - if (shouldShowSettingsScreen()) { - reset(/* switchToPersonalProfile */ false); - } - } - /** * @return {@code true} if the intent was re-routed to the DocumentsUI (and this * {@code PhotoPickerActivity} is {@link #isFinishing()} now). {@code false} - otherwise. @@ -425,7 +437,10 @@ public class PhotoPickerActivity extends AppCompatActivity { @Override public void onStateChanged(@NonNull View bottomSheet, int newState) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { + mPickerViewModel.logSwipeDownExit(); finish(); + } else if (newState == BottomSheetBehavior.STATE_EXPANDED) { + mPickerViewModel.logExpandToFullScreen(); } saveBottomSheetState(); } @@ -469,7 +484,7 @@ public class PhotoPickerActivity extends AppCompatActivity { } private void initStateForBottomSheet() { - if (!mIsAccessibilityEnabled && !mSelection.canSelectMultiple() + if (!isAccessibilityEnabled() && !mSelection.canSelectMultiple() && !isOrientationLandscape()) { final int peekHeight = getBottomSheetPeekHeight(this); mBottomSheetBehavior.setPeekHeight(peekHeight); @@ -480,6 +495,15 @@ public class PhotoPickerActivity extends AppCompatActivity { } } + /** + * Warning: This method is visible for espresso tests, we are not customizing anything here. + * Allowing ourselves to control the accessibility state helps us mock it for these tests. + */ + @VisibleForTesting + protected boolean isAccessibilityEnabled() { + return getSystemService(AccessibilityManager.class).isEnabled(); + } + private static int getBottomSheetPeekHeight(Context context) { final WindowManager windowManager = context.getSystemService(WindowManager.class); final Rect displayBounds = windowManager.getCurrentWindowMetrics().getBounds(); @@ -515,13 +539,19 @@ public class PhotoPickerActivity extends AppCompatActivity { return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; } + public LiveData<Boolean> isItemPhotoGridViewChanged() { + return mIsItemPhotoGridViewChanged; + } + public void setResultAndFinishSelf() { logPickerSelectionConfirmed(mSelection.getSelectedItems().size()); - if (shouldPreloadSelectedItems()) { - final var uris = PickerResult.getPickerUrisForItems(mSelection.getSelectedItems()); + final var uris = PickerResult.getPickerUrisForItems( + mSelection.getSelectedItems()); + mPickerViewModel.logPreloadingStarted(uris.size()); mPreloaderInstanceHolder.preloader = SelectedMediaPreloader.preload(/* activity */ this, uris); + deSelectUnavailableMedia(mPreloaderInstanceHolder.preloader); subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader); } else { setResultAndFinishSelfInternal(); @@ -546,11 +576,30 @@ public class PhotoPickerActivity extends AppCompatActivity { // The permission controller will pass the requesting package's UID here final Bundle extras = getIntent().getExtras(); final int uid = extras.getInt(Intent.EXTRA_UID); - final List<Uri> uris = getPickerUrisForItems(mSelection.getSelectedItems()); - ForegroundThread.getExecutor().execute(() -> { - // Handle grants in another thread to not block the UI. - grantMediaReadForPackage(getApplicationContext(), uid, uris); - }); + final List<Uri> uris = getPickerUrisForItems(mSelection.getSelectedItemsWithoutGrants()); + if (!uris.isEmpty()) { + ForegroundThread.getExecutor().execute(() -> { + // Handle grants in another thread to not block the UI. + grantMediaReadForPackage(getApplicationContext(), uid, uris); + mPickerViewModel.logPickerChoiceAddedGrantsCount(uris.size(), extras); + }); + } + + // Revoke READ_GRANT for items that were pre-granted but now in the current session user has + // deselected them. + if (mPickerViewModel.isManagedSelectionEnabled()) { + final List<Uri> urisForItemsWhoseGrantsNeedsToBeRevoked = getPickerUrisForItems( + mSelection.getPreGrantedItemsToBeRevoked()); + if (!urisForItemsWhoseGrantsNeedsToBeRevoked.isEmpty()) { + ForegroundThread.getExecutor().execute(() -> { + // Handle grants in another thread to not block the UI. + MediaStore.revokeMediaReadForPackages(getApplicationContext(), uid, + urisForItemsWhoseGrantsNeedsToBeRevoked); + mPickerViewModel.logPickerChoiceRevokedGrantsCount( + urisForItemsWhoseGrantsNeedsToBeRevoked.size(), extras); + }); + } + } } private void setResultForPickImagesOrGetContentAction() { @@ -568,7 +617,7 @@ public class PhotoPickerActivity extends AppCompatActivity { final boolean isGetContent = isGetContentAction(); final boolean isPickImages = isPickImagesAction(); - final ConfigStore cs = getConfigStore(); + final ConfigStore cs = mPickerViewModel.getConfigStore(); if (getIntent().hasExtra(EXTRA_PRELOAD_SELECTED)) { if (Build.isDebuggable() @@ -593,11 +642,44 @@ public class PhotoPickerActivity extends AppCompatActivity { /* lifecycleOwner */ PhotoPickerActivity.this, isFinished -> { if (isFinished) { + mPickerViewModel.logPreloadingFinished(); setResultAndFinishSelfInternal(); } }); } + // This method is responsible for deselecting all unavailable items from selection list + // when user tries selecting unavailable could only media (not cached) while offline + private void deSelectUnavailableMedia(@NonNull SelectedMediaPreloader preloader) { + preloader.getUnavailableMediaIndexes().observe( + /* lifecycleOwner */ PhotoPickerActivity.this, + unavailableMediaIndexes -> { + if (unavailableMediaIndexes.size() > 0) { + // To notify the fragment to uncheck the unavailable items at UI those are + // no longer available in the selection list. + mIsItemPhotoGridViewChanged.postValue(true); + + // Checking if preloading was intentionally be cancelled by the user + if (unavailableMediaIndexes.get(unavailableMediaIndexes.size() - 1) != -1) { + // Displaying error dialog with an error message when the user tries + // to add unavailable cloud only media (not cached) while offline. + DialogUtils.showDialog(this, + getResources().getString(R.string.dialog_error_title), + getResources().getString(R.string.dialog_error_message)); + mPickerViewModel.logPreloadingFailed(unavailableMediaIndexes.size()); + } else { + unavailableMediaIndexes.remove( + unavailableMediaIndexes.size() - 1); + mPickerViewModel.logPreloadingCancelled(unavailableMediaIndexes.size()); + } + List<Item> selectedItems = mSelection.getSelectedItems(); + for (var mediaIndex : unavailableMediaIndexes) { + mSelection.removeSelectedItem(selectedItems.get(mediaIndex)); + } + } + }); + } + /** * NOTE: this may wrongly return {@code false} if called before {@link PickerViewModel} had a * chance to fetch the authority and the account of the current @@ -825,10 +907,33 @@ public class PhotoPickerActivity extends AppCompatActivity { /** * Reset to Photo Picker initial launch state (Photos grid tab) in personal profile mode. - * @param switchToPersonalProfile is true then set personal profile as current profile. */ - private void reset(boolean switchToPersonalProfile) { - mPickerViewModel.reset(switchToPersonalProfile); + private void resetToPersonalProfile() { + // Clear all the fragments in the FragmentManager + final FragmentManager fragmentManager = getSupportFragmentManager(); + fragmentManager.popBackStackImmediate(/* name */ null, + FragmentManager.POP_BACK_STACK_INCLUSIVE); + + // Reset all content to the personal profile + mPickerViewModel.resetToPersonalProfile(); + + // Set up the fragments same as the initial launch state + setupInitialLaunchState(); + } + + /** + * Reset to Photo Picker initial launch state (Photos grid tab) in the current profile mode. + */ + private void resetInCurrentProfile() { + // Clear all the fragments in the FragmentManager + final FragmentManager fragmentManager = getSupportFragmentManager(); + fragmentManager.popBackStackImmediate(/* name */ null, + FragmentManager.POP_BACK_STACK_INCLUSIVE); + + // Reset all content in the current profile + mPickerViewModel.resetAllContentInCurrentProfile(); + + // Set up the fragments same as the initial launch state setupInitialLaunchState(); } @@ -958,14 +1063,9 @@ public class PhotoPickerActivity extends AppCompatActivity { } private void switchToPersonalProfileInitialLaunchState() { - final FragmentManager fragmentManager = getSupportFragmentManager(); - // Clear all back stacks in FragmentManager - fragmentManager.popBackStackImmediate(/* name */ null, - FragmentManager.POP_BACK_STACK_INCLUSIVE); - // We reset the state of the PhotoPicker as we do not want to make any // assumptions on the state of the PhotoPicker when it was in Work Profile mode. - reset(/* switchToPersonalProfile */ true); + resetToPersonalProfile(); } } @@ -979,4 +1079,17 @@ public class PhotoPickerActivity extends AppCompatActivity { @Nullable SelectedMediaPreloader preloader; } + + /** + * Reset the Picker view model content when launched with cloud features and notified to + * refresh the UI. + */ + private void observeRefreshUiNotificationLiveData() { + mPickerViewModel.shouldRefreshUiLiveData() + .observe(this, shouldRefresh -> { + if (shouldRefresh && !mPickerViewModel.shouldShowOnlyLocalFeatures()) { + resetInCurrentProfile(); + } + }); + } } diff --git a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java index c71f600a2..c7336fd96 100644 --- a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java +++ b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java @@ -46,7 +46,6 @@ import com.android.providers.media.PickerUriResolver; import com.android.providers.media.photopicker.data.CloudProviderQueryExtras; import com.android.providers.media.photopicker.data.ExternalDbFacade; - import java.io.FileNotFoundException; /** @@ -71,7 +70,7 @@ public class PhotoPickerProvider extends CloudMediaProvider { CloudProviderQueryExtras.fromCloudMediaBundle(extras); return mDbFacade.queryMedia(queryExtras.getGeneration(), queryExtras.getAlbumId(), - queryExtras.getMimeTypes()); + queryExtras.getMimeTypes(), queryExtras.getPageSize(), queryExtras.getPageToken()); } @Override diff --git a/src/com/android/providers/media/photopicker/PickerDataLayer.java b/src/com/android/providers/media/photopicker/PickerDataLayer.java index 4f0bb57db..49b3d4bae 100644 --- a/src/com/android/providers/media/photopicker/PickerDataLayer.java +++ b/src/com/android/providers/media/photopicker/PickerDataLayer.java @@ -22,9 +22,12 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.AUTHORITY import static android.provider.CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_NAME; +import static android.provider.MediaStore.MY_UID; import static com.android.providers.media.PickerUriResolver.getAlbumUri; import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_ALBUM_SYNC_WORK_NAME; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_LOCAL_SYNC_WORK_NAME; import static java.util.Objects.requireNonNull; @@ -41,15 +44,33 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.work.Configuration; +import androidx.work.WorkManager; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.logging.InstanceId; +import com.android.providers.media.ConfigStore; import com.android.providers.media.photopicker.data.CloudProviderQueryExtras; import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.data.PickerSyncRequestExtras; +import com.android.providers.media.photopicker.metrics.NonUiEventLogger; +import com.android.providers.media.photopicker.sync.PickerSyncManager; +import com.android.providers.media.photopicker.sync.SyncTracker; +import com.android.providers.media.photopicker.sync.SyncTrackerRegistry; +import com.android.providers.media.util.ForegroundThread; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; /** * Fetches data for the picker UI from the db and cloud/local providers @@ -58,20 +79,62 @@ public class PickerDataLayer { private static final String TAG = "PickerDataLayer"; private static final boolean DEBUG = false; private static final boolean DEBUG_DUMP_CURSORS = false; + private static final long CLOUD_SYNC_TIMEOUT_MILLIS = 500L; public static final String QUERY_ARG_LOCAL_ONLY = "android:query-arg-local-only"; + public static final String QUERY_DATE_TAKEN_BEFORE_MS = "android:query-date-taken-before-ms"; + + public static final String QUERY_LOCAL_ID_SELECTION = "android:query-local-id-selection"; + + public static final String QUERY_ROW_ID = "android:query-row-id"; + + // Thread pool size should be at least equal to the number of unique work requests in + // {@link PickerSyncManager} to ensure that any request type is not blocked on other request + // types. It is advisable to use unique work requests because in case the number of queued + // requests grows, they should not block other work requests. + private static final int WORK_MANAGER_THREAD_POOL_SIZE = 6; + @Nullable + private static volatile Executor sWorkManagerExecutor; + + @NonNull private final Context mContext; + @NonNull private final PickerDbFacade mDbFacade; + @NonNull private final PickerSyncController mSyncController; + @NonNull + private final PickerSyncManager mSyncManager; + @NonNull private final String mLocalProvider; + @NonNull + private final ConfigStore mConfigStore; + + @VisibleForTesting + public PickerDataLayer(@NonNull Context context, @NonNull PickerDbFacade dbFacade, + @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore, + @NonNull PickerSyncManager syncManager) { + mContext = requireNonNull(context); + mDbFacade = requireNonNull(dbFacade); + mSyncController = requireNonNull(syncController); + mLocalProvider = requireNonNull(dbFacade.getLocalProvider()); + mConfigStore = requireNonNull(configStore); + mSyncManager = syncManager; + + // Add a subscriber to config store changes to monitor the allowlist. + mConfigStore.addOnChangeListener( + ForegroundThread.getExecutor(), + this::validateCurrentCloudProviderOnAllowlistChange); + } - public PickerDataLayer(Context context, PickerDbFacade dbFacade, - PickerSyncController syncController) { - mContext = context; - mDbFacade = dbFacade; - mSyncController = syncController; - mLocalProvider = dbFacade.getLocalProvider(); + /** + * Create a new instance of PickerDataLayer. + */ + public static PickerDataLayer create(@NonNull Context context, @NonNull PickerDbFacade dbFacade, + @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore) { + PickerSyncManager syncManager = new PickerSyncManager( + getWorkManager(context), context, configStore, /* schedulePeriodicSyncs */ true); + return new PickerDataLayer(context, dbFacade, syncController, configStore, syncManager); } /** @@ -112,15 +175,24 @@ public class PickerDataLayer { // Use media table for all media except albums. Merged categories like, // favorites and video are tagged in the media table and are not a part of // album_media. - if (TextUtils.isEmpty(albumId) || isMergedAlbum(queryExtras)) { + if (TextUtils.isEmpty(albumId) || queryExtras.isMergedAlbum()) { // Refresh the 'media' table - syncAllMedia(isLocalOnly); - - if (!isLocalOnly && TextUtils.isEmpty(albumId)) { - // TODO(b/257887919): Build proper UI and remove this. - // Notify that the picker is launched in case there's any pending UI - // notification - mSyncController.notifyPickerLaunch(); + if (shouldSyncBeforePickerQuery()) { + syncAllMedia(isLocalOnly); + } else { + // Wait for local sync to finish indefinitely + waitForSync(SyncTrackerRegistry.getLocalSyncTracker(), + IMMEDIATE_LOCAL_SYNC_WORK_NAME); + Log.i(TAG, "Local sync is complete"); + + // Wait for on cloud sync with timeout + if (!isLocalOnly) { + boolean syncIsComplete = waitForSyncWithTimeout( + SyncTrackerRegistry.getCloudSyncTracker(), + CLOUD_SYNC_TIMEOUT_MILLIS); + Log.i(TAG, "Finished waiting for cloud sync. Is cloud sync complete: " + + syncIsComplete); + } } // Fetch all merged and deduped cloud and local media from 'media' table @@ -138,7 +210,13 @@ public class PickerDataLayer { // The album type here can only be local or cloud because merged categories like, // Favorites and Videos would hit the first condition. // Refresh the 'album_media' table - mSyncController.syncAlbumMedia(albumId, isLocal(albumAuthority)); + if (shouldSyncBeforePickerQuery()) { + mSyncController.syncAlbumMedia(albumId, isLocal(albumAuthority)); + } else { + waitForSync(SyncTrackerRegistry.getAlbumSyncTracker(isLocal(albumAuthority)), + IMMEDIATE_ALBUM_SYNC_WORK_NAME); + Log.i(TAG, "Album sync is complete"); + } // Fetch album specific media for local or cloud from 'album_media' table result = mDbFacade.queryAlbumMediaForUi( @@ -162,20 +240,77 @@ public class PickerDataLayer { private void syncAllMedia(boolean isLocalOnly) { if (isLocalOnly) { - mSyncController.syncAllMediaFromLocalProvider(); + mSyncController.syncAllMediaFromLocalProvider(/* cancellationSignal= */ null); } else { mSyncController.syncAllMedia(); } } /** - * Checks if the query is for a merged album type. - * Some albums are not cloud only, they are merged from files on devices and the cloudprovider. + * Will try it's best to wait for the existing sync requests to complete. It may not wait for + * new sync requests received after this method starts running. + */ + private void waitForSync(@NonNull SyncTracker syncTracker, String uniqueWorkName) { + try { + final CompletableFuture<Void> completableFuture = + CompletableFuture.allOf( + syncTracker.pendingSyncFutures().toArray(new CompletableFuture[0])); + + waitForSync(completableFuture, uniqueWorkName, /* retryCount */ 30); + } catch (ExecutionException | InterruptedException e) { + Log.w(TAG, "Could not wait for the sync to finish: " + e); + } + } + + /** + * Wait for sync tracked by the input future to complete. In case the future takes an unusually + * long time to complete, check the relevant unique work status from Work Manager. + */ + @VisibleForTesting + public int waitForSync(@NonNull CompletableFuture<Void> completableFuture, + @NonNull String uniqueWorkName, + int retryCount) throws ExecutionException, InterruptedException { + for (; retryCount > 0; retryCount--) { + try { + completableFuture.get(/* timeout */ 3, TimeUnit.SECONDS); + return retryCount; + } catch (TimeoutException e) { + if (mSyncManager.isUniqueWorkPending(uniqueWorkName)) { + Log.i(TAG, "Waiting for the sync again." + + " Unique work name: " + uniqueWorkName + + " Retry count: " + retryCount); + } else { + Log.e(TAG, "Either immediate unique work is complete and the sync futures " + + "were not cleared, or a proactive sync might be blocking the query. " + + "Unblocking the query now for " + uniqueWorkName); + return retryCount; + } + } + } + + if (retryCount == 0) { + Log.e(TAG, "Retry count exhausted, could not wait for sync anymore."); + } + return retryCount; + } + + /** + * Will wait for the existing sync requests to complete till the provided timeout. It may + * not wait for new sync requests received after this method starts running. */ - private boolean isMergedAlbum(CloudProviderQueryExtras queryExtras) { - final boolean isFavorite = queryExtras.isFavorite(); - final boolean isVideo = queryExtras.isVideo(); - return isFavorite || isVideo; + private boolean waitForSyncWithTimeout( + @NonNull SyncTracker syncTracker, + @Nullable Long timeoutInMillis) { + try { + final CompletableFuture<Void> completableFuture = + CompletableFuture.allOf( + syncTracker.pendingSyncFutures().toArray(new CompletableFuture[0])); + completableFuture.get(timeoutInMillis, TimeUnit.MILLISECONDS); + return true; + } catch (ExecutionException | InterruptedException | TimeoutException e) { + Log.w(TAG, "Could not wait for the sync with timeout to finish: " + e); + return false; + } } /** @@ -209,9 +344,11 @@ public class PickerDataLayer { final boolean isLocalOnly = queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY, false); // Refresh the 'media' table so that 'merged' albums (Favorites and Videos) are // up-to-date - syncAllMedia(isLocalOnly); + if (shouldSyncBeforePickerQuery()) { + syncAllMedia(isLocalOnly); + } - final String cloudProvider = mDbFacade.getCloudProvider(); + final String cloudProvider = mSyncController.getCloudProvider(); final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs); final Bundle cloudMediaArgs = queryExtras.toCloudMediaBundle(); @@ -221,7 +358,8 @@ public class PickerDataLayer { cursorExtra.putString(MediaStore.EXTRA_LOCAL_PROVIDER, mLocalProvider); // Favorites and Videos are merged albums. - final Cursor mergedAlbums = mDbFacade.getMergedAlbums(queryExtras.toQueryFilter()); + final Cursor mergedAlbums = mDbFacade.getMergedAlbums(queryExtras.toQueryFilter(), + cloudProvider); if (mergedAlbums != null) { cursors.add(mergedAlbums); } @@ -293,7 +431,12 @@ public class PickerDataLayer { final Bundle accountBundle = mContext.getContentResolver() .call(getMediaCollectionInfoUri(cloudProvider), METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null, /* extras */ null); - + if (accountBundle == null) { + Log.e(TAG, + "Media collection info received is null. Failed to fetch Cloud account " + + "information."); + return null; + } final String accountName = accountBundle.getString(ACCOUNT_NAME); if (accountName == null) { return null; @@ -318,12 +461,22 @@ public class PickerDataLayer { } private Cursor queryProviderAlbumsInternal(@NonNull String authority, Bundle queryArgs) { + final InstanceId instanceId = NonUiEventLogger.generateInstanceId(); + int numberOfAlbumsFetched = -1; + NonUiEventLogger.logPickerGetAlbumsStart(instanceId, MY_UID, authority); try { - return mContext.getContentResolver().query(getAlbumUri(authority), + final Cursor res = mContext.getContentResolver().query(getAlbumUri(authority), /* projection */ null, queryArgs, /* cancellationSignal */ null); + if (res != null) { + numberOfAlbumsFetched = res.getCount(); + } + return res; } catch (Exception e) { Log.w(TAG, "Failed to fetch cloud albums for: " + authority, e); return null; + } finally { + NonUiEventLogger.logPickerGetAlbumsEnd(instanceId, MY_UID, authority, + numberOfAlbumsFetched); } } @@ -344,6 +497,68 @@ public class PickerDataLayer { return sb.toString(); } + /** + * Triggers a sync operation based on the parameters. + */ + public void initMediaData(@NonNull PickerSyncRequestExtras syncRequestExtras) { + if (syncRequestExtras.shouldSyncMediaData()) { + // Sync media data + Log.i(TAG, "Init data request for the main photo grid i.e. media data." + + " Should sync with local provider only: " + + syncRequestExtras.shouldSyncLocalOnlyData()); + + mSyncManager.syncMediaImmediately(syncRequestExtras.shouldSyncLocalOnlyData()); + } else { + // Sync album media data + Log.i(TAG, String.format("Init data request for album content of: %s" + + " Should sync with local provider only: %b", + syncRequestExtras.getAlbumId(), + syncRequestExtras.shouldSyncLocalOnlyData())); + + validateAlbumMediaSyncArgs(syncRequestExtras); + + // We don't need to sync in case of merged albums + if (!syncRequestExtras.shouldSyncMergedAlbum()) { + mSyncManager.syncAlbumMediaForProviderImmediately( + syncRequestExtras.getAlbumId(), + syncRequestExtras.getAlbumAuthority()); + } + } + } + + private void validateAlbumMediaSyncArgs(PickerSyncRequestExtras syncRequestExtras) { + if (!syncRequestExtras.shouldSyncMediaData()) { + Objects.requireNonNull(syncRequestExtras.getAlbumId(), + "Album Id can't be null for an album sync request."); + Objects.requireNonNull(syncRequestExtras.getAlbumAuthority(), + "Album authority can't be null for an album sync request."); + } + if (!syncRequestExtras.shouldSyncMediaData() + && !syncRequestExtras.shouldSyncMergedAlbum() + && syncRequestExtras.shouldSyncLocalOnlyData() + && !isLocal(syncRequestExtras.getAlbumAuthority())) { + throw new IllegalStateException( + "Can't exclude cloud contents in cloud album " + + syncRequestExtras.getAlbumAuthority()); + } + } + + + /** + * Handles notification about media events like inserts/updates/deletes received from cloud or + * local providers. + * @param localOnly - whether the media event is coming from the local provider + */ + public void handleMediaEventNotification(Boolean localOnly) { + try { + mSyncManager.syncMediaProactively(localOnly); + } catch (RuntimeException e) { + // Catch any unchecked exceptions so that critical paths in MP that call this method are + // not affected by Picker related issues. + Log.e(TAG, "Could not handle media event notification ", e); + } + } + public static class AccountInfo { public final String accountName; public final Intent accountConfigurationIntent; @@ -365,6 +580,7 @@ public class PickerDataLayer { @NonNull static final Map<String, Integer> COLUMN_NAME_TO_INDEX_MAP; static final int AUTHORITY_COLUMN_INDEX; + static { final Map<String, Integer> map = new HashMap<>(); for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) { @@ -449,5 +665,90 @@ public class PickerDataLayer { // is stored in the cursor. return mAuthority; } + + @Override + public int getType(int columnIndex) { + // 1. Get value from the underlying cursor. + final int cursorColumnIndex = mColumnIndexToCursorColumnIndexArray[columnIndex]; + final int cursorValue = cursorColumnIndex != -1 + ? getWrappedCursor().getType(cursorColumnIndex) : Cursor.FIELD_TYPE_NULL; + + // 2a. If this is NOT the AUTHORITY column: just return the value. + if (columnIndex != AUTHORITY_COLUMN_INDEX) { + return cursorValue; + } + + // 2b. If this IS the AUTHORITY column: "override" whatever value (which may be 0) + // is stored in the cursor. + return Cursor.FIELD_TYPE_STRING; + } + } + + /** + * Initialize the {@link WorkManager} if it is not initialized already. + * + * @return a {@link WorkManager} object that can be used to run work requests. + */ + @NonNull + private static WorkManager getWorkManager(Context mContext) { + if (!WorkManager.isInitialized()) { + Log.i(TAG, "Work manager not initialised. Attempting to initialise."); + WorkManager.initialize(mContext, getWorkManagerConfiguration()); + } + return WorkManager.getInstance(mContext); + } + + @NonNull + private static Configuration getWorkManagerConfiguration() { + ensureWorkManagerExecutor(); + return new Configuration.Builder() + .setMinimumLoggingLevel(Log.INFO) + .setExecutor(sWorkManagerExecutor) + .build(); + } + + private static void ensureWorkManagerExecutor() { + if (sWorkManagerExecutor == null) { + synchronized (PickerDataLayer.class) { + if (sWorkManagerExecutor == null) { + sWorkManagerExecutor = Executors + .newFixedThreadPool(WORK_MANAGER_THREAD_POOL_SIZE); + } + } + } + } + + /** + * For cloud feature enabled scenarios, sync request is sent from the + * MediaStore.PICKER_MEDIA_INIT_CALL method call once when a fresh grid needs to be filled + * populated data. This is because UI paginated queries are supported when cloud feature + * enabled. This avoids triggering a sync for the same dataset for each paged query received + * from the UI. + */ + private boolean shouldSyncBeforePickerQuery() { + return !mConfigStore.isCloudMediaInPhotoPickerEnabled(); + } + + /** + * Checks the current allowed list of Cloud Provider packages, and ensures that the currently + * set provider is a member of the allowlist. In the event the current Cloud Provider is not on + * the list, the current Cloud Provider is removed. + */ + private void validateCurrentCloudProviderOnAllowlistChange() { + + List<String> currentAllowlist = mConfigStore.getAllowedCloudProviderPackages(); + String currentCloudProvider = mSyncController.getCurrentCloudProviderInfo().packageName; + + if (!currentAllowlist.contains(currentCloudProvider)) { + Log.d( + TAG, + String.format( + "Cloud provider allowlist was changed, and the current cloud provider" + + " is no longer on the allowlist." + + " Allowlist: %s" + + " Current Provider: %s", + currentAllowlist.toString(), currentCloudProvider)); + mSyncController.notifyPackageRemoval(currentCloudProvider); + } } } diff --git a/src/com/android/providers/media/photopicker/PickerSyncController.java b/src/com/android/providers/media/photopicker/PickerSyncController.java index 05c38a99b..eb7df4fdb 100644 --- a/src/com/android/providers/media/photopicker/PickerSyncController.java +++ b/src/com/android/providers/media/photopicker/PickerSyncController.java @@ -19,49 +19,59 @@ package com.android.providers.media.photopicker; import static android.content.ContentResolver.EXTRA_HONORED_ARGS; import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID; import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE; import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.MEDIA_COLLECTION_ID; +import static android.provider.MediaStore.MY_UID; +import static com.android.providers.media.PickerUriResolver.PICKER_INTERNAL_URI; +import static com.android.providers.media.PickerUriResolver.REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI; import static com.android.providers.media.PickerUriResolver.getDeletedMediaUri; import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri; import static com.android.providers.media.PickerUriResolver.getMediaUri; +import static com.android.providers.media.photopicker.NotificationContentObserver.ALBUM_CONTENT; +import static com.android.providers.media.photopicker.NotificationContentObserver.MEDIA; +import static com.android.providers.media.photopicker.NotificationContentObserver.UPDATE; +import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString; import android.annotation.IntDef; +import android.content.ContentResolver; import android.content.Context; import android.content.SharedPreferences; -import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.os.CancellationSignal; import android.os.Handler; -import android.os.Process; import android.os.Trace; import android.os.storage.StorageManager; import android.provider.CloudMediaProvider; import android.provider.CloudMediaProviderContract; +import android.provider.CloudMediaProviderContract.MediaColumns; import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; -import android.widget.Toast; -import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.internal.logging.InstanceId; import com.android.modules.utils.BackgroundThread; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.ConfigStore; -import com.android.providers.media.R; import com.android.providers.media.photopicker.data.CloudProviderInfo; import com.android.providers.media.photopicker.data.PickerDbFacade; -import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; +import com.android.providers.media.photopicker.metrics.NonUiEventLogger; +import com.android.providers.media.photopicker.sync.CloseableReentrantLock; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; import com.android.providers.media.photopicker.util.CloudProviderUtils; import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException; -import com.android.providers.media.util.ForegroundThread; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; +import java.io.PrintWriter; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; @@ -82,11 +92,14 @@ public class PickerSyncController { private static final boolean DEBUG = false; private static final String PREFS_KEY_CLOUD_PROVIDER_AUTHORITY = "cloud_provider_authority"; - private static final String PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATION = - "cloud_provider_pending_notification"; private static final String PREFS_KEY_CLOUD_PREFIX = "cloud_provider:"; private static final String PREFS_KEY_LOCAL_PREFIX = "local_provider:"; + private static final String PREFS_KEY_RESUME = "resume"; + private static final String PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX = "media_add:"; + private static final String PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX = "media_remove:"; + private static final String PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX = "album_add:"; + private static final String PICKER_USER_PREFS_FILE_NAME = "picker_user_prefs"; public static final String PICKER_SYNC_PREFS_FILE_NAME = "picker_sync_prefs"; public static final String LOCAL_PICKER_PROVIDER_AUTHORITY = @@ -94,10 +107,22 @@ public class PickerSyncController { private static final String PREFS_VALUE_CLOUD_PROVIDER_UNSET = "-"; + private static final int OPERATION_ADD_MEDIA = 1; + private static final int OPERATION_ADD_ALBUM = 2; + private static final int OPERATION_REMOVE_MEDIA = 3; + + @IntDef( + flag = false, + value = {OPERATION_ADD_MEDIA, OPERATION_ADD_ALBUM, OPERATION_REMOVE_MEDIA}) + @Retention(RetentionPolicy.SOURCE) + private @interface OperationType {} + private static final int SYNC_TYPE_NONE = 0; private static final int SYNC_TYPE_MEDIA_INCREMENTAL = 1; private static final int SYNC_TYPE_MEDIA_FULL = 2; private static final int SYNC_TYPE_MEDIA_RESET = 3; + private static final int SYNC_TYPE_MEDIA_FULL_WITH_RESET = 4; + public static final int PAGE_SIZE = 1000; @NonNull private static final Handler sBgThreadHandler = BackgroundThread.getHandler(); @IntDef(flag = false, prefix = { "SYNC_TYPE_" }, value = { @@ -105,35 +130,87 @@ public class PickerSyncController { SYNC_TYPE_MEDIA_INCREMENTAL, SYNC_TYPE_MEDIA_FULL, SYNC_TYPE_MEDIA_RESET, + SYNC_TYPE_MEDIA_FULL_WITH_RESET, }) @Retention(RetentionPolicy.SOURCE) private @interface SyncType {} + private static final long DEFAULT_GENERATION = -1; private final Context mContext; private final ConfigStore mConfigStore; private final PickerDbFacade mDbFacade; private final SharedPreferences mSyncPrefs; private final SharedPreferences mUserPrefs; + private final PickerSyncLockManager mPickerSyncLockManager; private final String mLocalProvider; - private final long mSyncDelayMs; - private final Runnable mSyncAllMediaCallback; - - private final PhotoPickerUiEventLogger mLogger; - private final Object mCloudSyncLock = new Object(); - // TODO(b/278562157): If there is a dependency on the sync process, always acquire the - // {@link mCloudSyncLock} before {@link mCloudProviderLock} to avoid deadlock. - private final Object mCloudProviderLock = new Object(); - @GuardedBy("mCloudProviderLock") + private CloudProviderInfo mCloudProviderInfo; + @Nullable + private static PickerSyncController sInstance; - public PickerSyncController(@NonNull Context context, @NonNull PickerDbFacade dbFacade, - @NonNull ConfigStore configStore) { - this(context, dbFacade, configStore, LOCAL_PICKER_PROVIDER_AUTHORITY); + /** + * Initialize {@link PickerSyncController} object.{@link PickerSyncController} should only be + * initialized from {@link com.android.providers.media.MediaProvider#onCreate}. + * + * @param context the app context of type {@link Context} + * @param dbFacade instance of {@link PickerDbFacade} that will be used for DB queries. + * @param configStore {@link ConfigStore} that returns the sync config of the device. + * @return an instance of {@link PickerSyncController} + */ + @NonNull + public static PickerSyncController initialize(@NonNull Context context, + @NonNull PickerDbFacade dbFacade, @NonNull ConfigStore configStore, @NonNull + PickerSyncLockManager pickerSyncLockManager) { + return initialize(context, dbFacade, configStore, pickerSyncLockManager, + LOCAL_PICKER_PROVIDER_AUTHORITY); } + /** + * Initialize {@link PickerSyncController} object.{@link PickerSyncController} should only be + * initialized from {@link com.android.providers.media.MediaProvider#onCreate}. + * + * @param context the app context of type {@link Context} + * @param dbFacade instance of {@link PickerDbFacade} that will be used for DB queries. + * @param configStore {@link ConfigStore} that returns the sync config of the device. + * @param localProvider is the name of the local provider that is responsible for providing the + * local media items. + * @return an instance of {@link PickerSyncController} + */ + @NonNull @VisibleForTesting - public PickerSyncController(@NonNull Context context, @NonNull PickerDbFacade dbFacade, - @NonNull ConfigStore configStore, @NonNull String localProvider) { + public static PickerSyncController initialize(@NonNull Context context, + @NonNull PickerDbFacade dbFacade, @NonNull ConfigStore configStore, + @NonNull PickerSyncLockManager pickerSyncLockManager, @NonNull String localProvider) { + sInstance = new PickerSyncController(context, dbFacade, configStore, pickerSyncLockManager, + localProvider); + return sInstance; + } + + /** + * This method is available for injecting a mock instance from tests. PickerSyncController is + * used in Worker classes. They cannot directly be injected with a mock controller instance. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setInstance(PickerSyncController controller) { + sInstance = controller; + } + + /** + * Returns PickerSyncController instance if it is initialized else throws an exception. + * @return a PickerSyncController object. + * @throws IllegalStateException when the PickerSyncController is not initialized. + */ + @NonNull + public static PickerSyncController getInstanceOrThrow() throws IllegalStateException { + if (sInstance == null) { + throw new IllegalStateException("PickerSyncController is not initialised."); + } + return sInstance; + } + + private PickerSyncController(@NonNull Context context, @NonNull PickerDbFacade dbFacade, + @NonNull ConfigStore configStore, @NonNull PickerSyncLockManager pickerSyncLockManager, + @NonNull String localProvider) { mContext = context; mConfigStore = configStore; mSyncPrefs = mContext.getSharedPreferences(PICKER_SYNC_PREFS_FILE_NAME, @@ -141,16 +218,22 @@ public class PickerSyncController { mUserPrefs = mContext.getSharedPreferences(PICKER_USER_PREFS_FILE_NAME, Context.MODE_PRIVATE); mDbFacade = dbFacade; + mPickerSyncLockManager = pickerSyncLockManager; mLocalProvider = localProvider; - mSyncAllMediaCallback = this::syncAllMedia; - mLogger = new PhotoPickerUiEventLogger(); - mSyncDelayMs = configStore.getPickerSyncDelayMs(); + // Listen to the device config, and try to enable cloud features when the config changes. + mConfigStore.addOnChangeListener(BackgroundThread.getExecutor(), this::initCloudProvider); initCloudProvider(); } + @NonNull + public PickerSyncLockManager getPickerSyncLockManager() { + return mPickerSyncLockManager; + } + private void initCloudProvider() { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (!mConfigStore.isCloudMediaInPhotoPickerEnabled()) { Log.d(TAG, "Cloud-Media-in-Photo-Picker feature is disabled during " + TAG + " construction."); @@ -194,62 +277,52 @@ public class PickerSyncController { Trace.beginSection(traceSectionName("syncAllMedia")); try { - syncAllMediaFromLocalProvider(); - syncAllMediaFromCloudProvider(); + syncAllMediaFromLocalProvider(/*CancellationSignal=*/ null); + syncAllMediaFromCloudProvider(/*CancellationSignal=*/ null); } finally { Trace.endSection(); } } - /** * Syncs the local media */ - public void syncAllMediaFromLocalProvider() { + public void syncAllMediaFromLocalProvider(@Nullable CancellationSignal cancellationSignal) { // Picker sync and special format update can execute concurrently and run into a deadlock. // Acquiring a lock before execution of each flow to avoid this. sIdleMaintenanceSyncLock.lock(); try { - syncAllMediaFromProvider(mLocalProvider, /* isLocal */ true, /* retryOnFailure */ true); + final InstanceId instanceId = NonUiEventLogger.generateInstanceId(); + syncAllMediaFromProvider(mLocalProvider, /* isLocal */ true, /* retryOnFailure */ true, + /* enablePagedSync= */ true, instanceId, cancellationSignal); } finally { sIdleMaintenanceSyncLock.unlock(); } } - private void syncAllMediaFromCloudProvider() { - synchronized (mCloudSyncLock) { - final String cloudProvider = getCloudProvider(); + /** + * Syncs the cloud media + */ + public void syncAllMediaFromCloudProvider(@Nullable CancellationSignal cancellationSignal) { - // Disable cloud queries in the database. If any cloud related queries come through - // while cloud sync is in progress, all cloud items will be ignored and local items will - // be returned. - mDbFacade.setCloudProvider(null); + try (CloseableReentrantLock ignored = + mPickerSyncLockManager.tryLock(PickerSyncLockManager.CLOUD_SYNC_LOCK)) { + final String cloudProvider = getCloudProviderWithTimeout(); // Trigger a sync. - final boolean isSyncCommitted = syncAllMediaFromProvider(cloudProvider, - /* isLocal */ false, /* retryOnFailure */ true); - - // Check if sync was committed i.e. the latest collection info was persisted. - if (!isSyncCommitted) { - Log.e(TAG, "Failed to sync with cloud provider - " + cloudProvider - + ". The cloud provider may have changed during the sync"); - return; - } - - // Reset the album_media table every time we sync all media - // TODO(258765155): do we really need to reset for both providers? - resetAlbumMedia(); - - // Re-enable cloud queries in the database for the latest cloud provider. - synchronized (mCloudProviderLock) { - if (Objects.equals(mCloudProviderInfo.authority, cloudProvider)) { - mDbFacade.setCloudProvider(cloudProvider); - } else { - Log.e(TAG, "Failed to sync with cloud provider - " + cloudProvider - + ". The cloud provider has changed to " - + mCloudProviderInfo.authority); - } + final InstanceId instanceId = NonUiEventLogger.generateInstanceId(); + final boolean didSyncFinish = syncAllMediaFromProvider(cloudProvider, + /* isLocal= */ false, /* retryOnFailure= */ true, /* enablePagedSync= */ true, + instanceId, cancellationSignal); + + // Check if sync was completed successfully. + if (!didSyncFinish) { + Log.e(TAG, "Failed to fully complete sync with cloud provider - " + cloudProvider + + ". The cloud provider may have changed during the sync, or only a" + + " partial sync was completed."); } + } catch (UnableToAcquireLockException e) { + Log.e(TAG, "Could not sync with the cloud provider", e); } } @@ -259,27 +332,37 @@ public class PickerSyncController { */ public void syncAlbumMedia(String albumId, boolean isLocal) { if (isLocal) { - syncAlbumMediaFromLocalProvider(albumId); + executeSyncAlbumReset(getLocalProvider(), isLocal, albumId); + syncAlbumMediaFromLocalProvider(albumId, /* cancellationSignal=*/ null); } else { - syncAlbumMediaFromCloudProvider(albumId); + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_ALBUM_SYNC_LOCK)) { + executeSyncAlbumReset(getCloudProviderWithTimeout(), isLocal, albumId); + } catch (UnableToAcquireLockException e) { + Log.e(TAG, "Unable to reset cloud album media " + albumId, e); + // Continue to attempt cloud album sync. This may show deleted album media on + // the album view. + } + syncAlbumMediaFromCloudProvider(albumId, /*cancellationSignal=*/ null); } } - private void syncAlbumMediaFromLocalProvider(@NonNull String albumId) { - syncAlbumMediaFromProvider(mLocalProvider, /* isLocal */ true, albumId); + /** Syncs album media from the local provider. */ + public void syncAlbumMediaFromLocalProvider( + @NonNull String albumId, @Nullable CancellationSignal cancellationSignal) { + syncAlbumMediaFromProvider(mLocalProvider, /* isLocal */ true, albumId, + /* enablePagedSync= */ true, cancellationSignal); } - private void syncAlbumMediaFromCloudProvider(@NonNull String albumId) { - synchronized (mCloudSyncLock) { - syncAlbumMediaFromProvider(getCloudProvider(), /* isLocal */ false, albumId); - } - } - - private void resetAlbumMedia() { - executeSyncAlbumReset(mLocalProvider, /* isLocal */ true, /* albumId */ null); - - synchronized (mCloudSyncLock) { - executeSyncAlbumReset(getCloudProvider(), /* isLocal */ false, /* albumId */ null); + /** Syncs album media from the currently enabled cloud {@link CloudMediaProvider}. */ + public void syncAlbumMediaFromCloudProvider( + @NonNull String albumId, @Nullable CancellationSignal cancellationSignal) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_ALBUM_SYNC_LOCK)) { + syncAlbumMediaFromProvider(getCloudProviderWithTimeout(), /* isLocal */ false, albumId, + /* enablePagedSync= */ true, cancellationSignal); + } catch (UnableToAcquireLockException e) { + Log.e(TAG, "Unable to sync cloud album media " + albumId, e); } } @@ -287,14 +370,20 @@ public class PickerSyncController { * Resets media library previously synced from the current {@link CloudMediaProvider} as well * as the {@link #mLocalProvider local provider}. */ - public void resetAllMedia() { + public void resetAllMedia() throws UnableToAcquireLockException { + // No need to acquire cloud lock for local reset. resetAllMedia(mLocalProvider, /* isLocal */ true); - synchronized (mCloudSyncLock) { + + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_SYNC_LOCK)) { + + // This does not fall in any sync path. Try to acquire the lock indefinitely. resetAllMedia(getCloudProvider(), /* isLocal */ false); } } - private boolean resetAllMedia(@Nullable String authority, boolean isLocal) { + private boolean resetAllMedia(@Nullable String authority, boolean isLocal) + throws UnableToAcquireLockException { Trace.beginSection(traceSectionName("resetAllMedia", isLocal)); try { executeSyncReset(authority, isLocal); @@ -373,13 +462,8 @@ public class PickerSyncController { Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); } - if (!mConfigStore.isCloudMediaInPhotoPickerEnabled()) { - Log.w(TAG, "Ignoring a request to set the CloudMediaProvider (" + authority + ") " - + "since the Cloud-Media-in-Photo-Picker feature is disabled"); - return false; - } - - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (Objects.equals(mCloudProviderInfo.authority, authority)) { Log.w(TAG, "Cloud provider already set: " + authority); return true; @@ -388,7 +472,8 @@ public class PickerSyncController { final CloudProviderInfo newProviderInfo = getCloudProviderInfo(authority, ignoreAllowList); if (authority == null || !newProviderInfo.isEmpty()) { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { // Disable cloud provider queries on the db until next sync // This will temporarily *clear* the cloud provider on the db facade and prevent // any queries from seeing cloud media until a sync where the cloud provider will be @@ -399,7 +484,7 @@ public class PickerSyncController { persistCloudProviderInfo(newProviderInfo, /* shouldUnset */ true); // TODO(b/242897322): Log from PickerViewModel using its InstanceId when relevant - mLogger.logPickerCloudProviderChanged(newProviderInfo.uid, + NonUiEventLogger.logPickerCloudProviderChanged(newProviderInfo.uid, newProviderInfo.packageName); Log.i(TAG, "Cloud provider changed successfully. Old: " + oldAuthority + ". New: " + newProviderInfo.authority); @@ -419,7 +504,8 @@ public class PickerSyncController { */ @NonNull public CloudProviderInfo getCurrentCloudProviderInfo() { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { return mCloudProviderInfo; } } @@ -430,19 +516,38 @@ public class PickerSyncController { * disabled by the user. */ private void setCurrentCloudProviderInfo(@NonNull CloudProviderInfo cloudProviderInfo) { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { mCloudProviderInfo = cloudProviderInfo; } } /** + * This should not be used in picker sync paths because we should not wait on a lock + * indefinitely during the picker sync process. + * Use {@link this#getCloudProviderWithTimeout()} instead. * @return {@link android.content.pm.ProviderInfo#authority authority} of the current * {@link CloudMediaProvider} or {@code null} if the {@link CloudMediaProvider} * integration is not enabled. */ @Nullable public String getCloudProvider() { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { + return mCloudProviderInfo.authority; + } + } + + /** + * @return {@link android.content.pm.ProviderInfo#authority authority} of the current + * {@link CloudMediaProvider} or {@code null} if the {@link CloudMediaProvider} + * integration is not enabled. This operation acquires a lock internally with a timeout. + * @throws UnableToAcquireLockException if the lock was not acquired within the given timeout. + */ + @Nullable + public String getCloudProviderWithTimeout() throws UnableToAcquireLockException { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { return mCloudProviderInfo.authority; } } @@ -460,7 +565,8 @@ public class PickerSyncController { return true; } - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (!mCloudProviderInfo.isEmpty() && Objects.equals(mCloudProviderInfo.authority, authority)) { return true; @@ -471,11 +577,12 @@ public class PickerSyncController { } public boolean isProviderEnabled(String authority, int uid) { - if (uid == Process.myUid() && mLocalProvider.equals(authority)) { + if (uid == MY_UID && mLocalProvider.equals(authority)) { return true; } - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (!mCloudProviderInfo.isEmpty() && uid == mCloudProviderInfo.uid && Objects.equals(mCloudProviderInfo.authority, authority)) { return true; @@ -486,7 +593,7 @@ public class PickerSyncController { } public boolean isProviderSupported(String authority, int uid) { - if (uid == Process.myUid() && mLocalProvider.equals(authority)) { + if (uid == MY_UID && mLocalProvider.equals(authority)) { return true; } @@ -505,22 +612,11 @@ public class PickerSyncController { } /** - * Notifies about media events like inserts/updates/deletes from cloud and local providers and - * syncs the changes in the background. - * - * There is a delay before executing the background sync to artificially throttle the burst - * notifications. - */ - public void notifyMediaEvent() { - sBgThreadHandler.removeCallbacks(mSyncAllMediaCallback); - sBgThreadHandler.postDelayed(mSyncAllMediaCallback, mSyncDelayMs); - } - - /** * Notifies about package removal */ public void notifyPackageRemoval(String packageName) { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (mCloudProviderInfo.matches(packageName)) { Log.i(TAG, "Package " + packageName + " is the current cloud provider and got removed"); @@ -530,7 +626,8 @@ public class PickerSyncController { } private void resetCloudProvider() { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { setCloudProvider(/* authority */ null); /** @@ -543,60 +640,37 @@ public class PickerSyncController { } } - // TODO(b/257887919): Build proper UI and remove this. /** - * Notifies about picker UI launched + * Syncs album media. + * + * @param enablePagedSync Set to true if the data from the provider may be synced in batches. + * If true, {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE + * is passed during query to the provider. */ - public void notifyPickerLaunch() { - final String authority = getCloudProvider(); - - final boolean hasPendingNotification = mUserPrefs.getBoolean( - PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATION, /* defaultValue */ false); - - if (!hasPendingNotification || (authority == null)) { - Log.d(TAG, "No pending UI notification"); - return; - } - - // Offload showing the UI on a fg thread to avoid the expensive binder request - // to fetch the app name blocking the picker launch - ForegroundThread.getHandler().post(() -> { - Log.i(TAG, "Cloud media now available in the picker"); - - final PackageManager pm = mContext.getPackageManager(); - final String appName = CloudProviderUtils.getProviderLabel(pm, authority); - - final String message = mContext.getResources().getString(R.string.picker_cloud_sync, - appName); - Toast.makeText(mContext, message, Toast.LENGTH_LONG).show(); - }); + private void syncAlbumMediaFromProvider(String authority, boolean isLocal, String albumId, + boolean enablePagedSync, @Nullable CancellationSignal cancellationSignal) { + final InstanceId instanceId = NonUiEventLogger.generateInstanceId(); + NonUiEventLogger.logPickerAlbumMediaSyncStart(instanceId, MY_UID, authority); - // Clear the notification - updateBooleanUserPref(PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATION, false); - } - - private void updateBooleanUserPref(String key, boolean value) { - final SharedPreferences.Editor editor = mUserPrefs.edit(); - editor.putBoolean(key, value); - editor.apply(); - } - - private void syncAlbumMediaFromProvider(String authority, boolean isLocal, String albumId) { final Bundle queryArgs = new Bundle(); queryArgs.putString(EXTRA_ALBUM_ID, albumId); + if (enablePagedSync) { + queryArgs.putInt(EXTRA_PAGE_SIZE, PAGE_SIZE); + } Trace.beginSection(traceSectionName("syncAlbumMediaFromProvider", isLocal)); try { - executeSyncAlbumReset(authority, isLocal, albumId); - if (authority != null) { - executeSyncAddAlbum(authority, isLocal, albumId, queryArgs); + executeSyncAddAlbum( + authority, isLocal, albumId, queryArgs, instanceId, cancellationSignal); } - } catch (RuntimeException e) { + } catch (RuntimeException | UnableToAcquireLockException e) { // Unlike syncAllMediaFromProvider, we don't retry here because any errors would have // occurred in fetching all the album_media since incremental sync is not supported. // A full sync is therefore unlikely to resolve any issue Log.e(TAG, "Failed to sync album media", e); + } catch (RequestObsoleteException e) { + Log.e(TAG, "Failed to sync all album media because authority has changed: ", e); } finally { Trace.endSection(); } @@ -604,9 +678,18 @@ public class PickerSyncController { /** * Returns true if the sync was successful and the latest collection info was persisted. + * + * @param enablePagedSync Set to true if the data from the provider may be synced in batches. + * If true, {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE} is passed + * during query to the provider. */ - private boolean syncAllMediaFromProvider(@Nullable String authority, boolean isLocal, - boolean retryOnFailure) { + private boolean syncAllMediaFromProvider( + @Nullable String authority, + boolean isLocal, + boolean retryOnFailure, + boolean enablePagedSync, + InstanceId instanceId, + @Nullable CancellationSignal cancellationSignal) { Log.d(TAG, "syncAllMediaFromProvider() " + (isLocal ? "LOCAL" : "CLOUD") + ", auth=" + authority + ", retry=" + retryOnFailure); @@ -617,52 +700,96 @@ public class PickerSyncController { Trace.beginSection(traceSectionName("syncAllMediaFromProvider", isLocal)); try { final SyncRequestParams params = getSyncRequestParams(authority, isLocal); - switch (params.syncType) { case SYNC_TYPE_MEDIA_RESET: // Can only happen when |authority| has been set to null and we need to clean up + disablePickerCloudMediaQueries(isLocal); return resetAllMedia(authority, isLocal); - case SYNC_TYPE_MEDIA_FULL: + case SYNC_TYPE_MEDIA_FULL_WITH_RESET: + disablePickerCloudMediaQueries(isLocal); if (!resetAllMedia(authority, isLocal)) { return false; } + enablePickerCloudMediaQueries(authority, isLocal); + + // Cache collection id with default generation id to prevent DB reset if full + // sync resumes the next time sync is triggered. + cacheMediaCollectionInfo( + authority, isLocal, + getDefaultGenerationCollectionInfo(params.latestMediaCollectionInfo)); + // Fall through to run full sync + case SYNC_TYPE_MEDIA_FULL: + NonUiEventLogger.logPickerFullSyncStart(instanceId, MY_UID, authority); + + // Send UI refresh notification for any active picker sessions, as the + // UI data might be stale if a full sync needs to be run. + sendPickerUiRefreshNotification(); + final Bundle fullSyncQueryArgs = new Bundle(); + if (enablePagedSync) { + fullSyncQueryArgs.putInt(EXTRA_PAGE_SIZE, params.mPageSize); + } // Pass a mutable empty bundle intentionally because it might be populated with // the next page token as part of a query to a cloud provider supporting // pagination executeSyncAdd(authority, isLocal, params.getMediaCollectionId(), - /* isIncrementalSync */ false, /* queryArgs */ new Bundle()); + /* isIncrementalSync */ false, fullSyncQueryArgs, + instanceId, cancellationSignal); // Commit sync position return cacheMediaCollectionInfo( authority, isLocal, params.latestMediaCollectionInfo); case SYNC_TYPE_MEDIA_INCREMENTAL: + enablePickerCloudMediaQueries(authority, isLocal); + NonUiEventLogger.logPickerIncrementalSyncStart(instanceId, MY_UID, authority); final Bundle queryArgs = new Bundle(); queryArgs.putLong(EXTRA_SYNC_GENERATION, params.syncGeneration); + if (enablePagedSync) { + queryArgs.putInt(EXTRA_PAGE_SIZE, params.mPageSize); + } - executeSyncAdd(authority, isLocal, params.getMediaCollectionId(), - /* isIncrementalSync */ true, queryArgs); - executeSyncRemove(authority, isLocal, params.getMediaCollectionId(), queryArgs); + executeSyncAdd( + authority, + isLocal, + params.getMediaCollectionId(), + /* isIncrementalSync */ true, + queryArgs, + instanceId, + cancellationSignal); + executeSyncRemove(authority, isLocal, params.getMediaCollectionId(), queryArgs, + instanceId, cancellationSignal); // Commit sync position return cacheMediaCollectionInfo( authority, isLocal, params.latestMediaCollectionInfo); case SYNC_TYPE_NONE: + enablePickerCloudMediaQueries(authority, isLocal); return true; default: throw new IllegalArgumentException("Unexpected sync type: " + params.syncType); } } catch (RequestObsoleteException e) { Log.e(TAG, "Failed to sync all media because authority has changed: ", e); - } catch (RuntimeException e) { - // Reset all media for the cloud provider in case it never succeeds - resetAllMedia(authority, isLocal); - - // Attempt a full sync. If this fails, the db table would have been reset, - // flushing all old content and leaving the picker UI empty. + } catch (IllegalStateException e) { + // If we're in an illegal state, reset and start a full sync again. + Log.e(TAG, "Failed to sync all media. Reset media and retry: " + retryOnFailure, e); + try { + resetAllMedia(authority, isLocal); + if (retryOnFailure) { + return syncAllMediaFromProvider(authority, isLocal, /* retryOnFailure */ false, + enablePagedSync, instanceId, cancellationSignal); + } + } catch (UnableToAcquireLockException ex) { + Log.e(TAG, "Could not reset media", e); + } + } catch (RuntimeException | UnableToAcquireLockException e) { + // Retry the failed operation to see if it was an intermittent problem. If this fails, + // the database will be in a partial state until the sync resumes from this point + // on next run. Log.e(TAG, "Failed to sync all media. Reset media and retry: " + retryOnFailure, e); if (retryOnFailure) { - return syncAllMediaFromProvider(authority, isLocal, /* retryOnFailure */ false); + return syncAllMediaFromProvider(authority, isLocal, /* retryOnFailure */ false, + enablePagedSync, instanceId, cancellationSignal); } } finally { Trace.endSection(); @@ -670,6 +797,33 @@ public class PickerSyncController { return false; } + /** + * Disable cloud media queries from Picker database. After disabling cloud media queries, when a + * media query will run on Picker database, only local media items will be returned. + */ + private void disablePickerCloudMediaQueries(boolean isLocal) + throws UnableToAcquireLockException { + if (!isLocal) { + mDbFacade.setCloudProviderWithTimeout(null); + } + } + + /** + * Enable cloud media queries from Picker database. After enabling cloud media queries, when a + * media query will run on Picker database, both local and cloud media items will be returned. + */ + private void enablePickerCloudMediaQueries(String authority, boolean isLocal) + throws UnableToAcquireLockException { + if (!isLocal) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { + if (Objects.equals(mCloudProviderInfo.authority, authority)) { + mDbFacade.setCloudProviderWithTimeout(authority); + } + } + } + } + private void executeSyncReset(String authority, boolean isLocal) { Log.i(TAG, "Executing SyncReset. isLocal: " + isLocal + ". authority: " + authority); @@ -703,8 +857,29 @@ public class PickerSyncController { } } - private void executeSyncAdd(String authority, boolean isLocal, - String expectedMediaCollectionId, boolean isIncrementalSync, Bundle queryArgs) { + /** + * Queries the provider and adds media to the picker database. + * + * @param authority Provider's authority + * @param isLocal Whether this is the local provider or not + * @param expectedMediaCollectionId The MediaCollectionId from the last sync point. + * @param isIncrementalSync If true, {@link CloudMediaProviderContract#EXTRA_SYNC_GENERATION} + * should be honoured by the provider. + * @param queryArgs Query arguments to pass in query. + * @param instanceId Metrics related Picker session instance Id. + * @param cancellationSignal CancellationSignal used to abort the sync. + * @throws RequestObsoleteException When the sync is interrupted due to the provider + * changing. + */ + private void executeSyncAdd( + String authority, + boolean isLocal, + String expectedMediaCollectionId, + boolean isIncrementalSync, + Bundle queryArgs, + InstanceId instanceId, + @Nullable CancellationSignal cancellationSignal) + throws RequestObsoleteException, UnableToAcquireLockException { final Uri uri = getMediaUri(authority); final List<String> expectedHonoredArgs = new ArrayList<>(); if (isIncrementalSync) { @@ -713,47 +888,120 @@ public class PickerSyncController { Log.i(TAG, "Executing SyncAdd. isLocal: " + isLocal + ". authority: " + authority); + String resumeKey = + getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX + PREFS_KEY_RESUME); + Trace.beginSection(traceSectionName("executeSyncAdd", isLocal)); - try (PickerDbFacade.DbWriteOperation operation = - mDbFacade.beginAddMediaOperation(authority)) { - executePagedSync(uri, expectedMediaCollectionId, expectedHonoredArgs, queryArgs, - operation); + try { + int syncedItems = executePagedSync( + uri, + expectedMediaCollectionId, + expectedHonoredArgs, + queryArgs, + resumeKey, + OPERATION_ADD_MEDIA, + authority, + isLocal, + cancellationSignal); + NonUiEventLogger.logPickerAddMediaSyncCompletion(instanceId, MY_UID, authority, + syncedItems); } finally { Trace.endSection(); } } - private void executeSyncAddAlbum(String authority, boolean isLocal, - String albumId, Bundle queryArgs) { + /** + * Queries the provider to sync media from the given albumId into the picker database. + * + * @param authority Provider's authority + * @param isLocal Whether this is the local provider or not + * @param albumId the Id of the album to sync + * @param queryArgs Query arguments to pass in query. + * @param instanceId Metrics related Picker session instance Id. + * @param cancellationSignal CancellationSignal used to abort the sync. + * @throws RequestObsoleteException When the sync is interrupted due to the provider + * changing. + */ + private void executeSyncAddAlbum( + String authority, + boolean isLocal, + String albumId, + Bundle queryArgs, + InstanceId instanceId, + @Nullable CancellationSignal cancellationSignal) + throws RequestObsoleteException, UnableToAcquireLockException { final Uri uri = getMediaUri(authority); Log.i(TAG, "Executing SyncAddAlbum. " + "isLocal: " + isLocal + ". authority: " + authority + ". albumId: " + albumId); + String resumeKey = + getPrefsKey(isLocal, PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX + PREFS_KEY_RESUME); Trace.beginSection(traceSectionName("executeSyncAddAlbum", isLocal)); - try (PickerDbFacade.DbWriteOperation operation = - mDbFacade.beginAddAlbumMediaOperation(authority, albumId)) { + try { // We don't need to validate the mediaCollectionId for album_media sync since it's // always a full sync - executePagedSync(uri, /* mediaCollectionId */ null, Arrays.asList(EXTRA_ALBUM_ID), - queryArgs, operation); + int syncedItems = + executePagedSync( + uri, /* mediaCollectionId */ + null, + List.of(EXTRA_ALBUM_ID), + queryArgs, + resumeKey, + OPERATION_ADD_ALBUM, + authority, + isLocal, + albumId, + /*cancellationSignal=*/ cancellationSignal); + NonUiEventLogger.logPickerAddAlbumMediaSyncCompletion(instanceId, MY_UID, authority, + syncedItems); } finally { Trace.endSection(); } } - private void executeSyncRemove(String authority, boolean isLocal, - String mediaCollectionId, Bundle queryArgs) { + /** + * Queries the provider and syncs removed media with the picker database. + * + * @param authority Provider's authority + * @param isLocal Whether this is the local provider or not + * @param mediaCollectionId The last synced media collection id + * @param queryArgs Query arguments to pass in query. + * @param instanceId Metrics related Picker session instance Id. + * @param cancellationSignal CancellationSignal used to abort the sync. + * @throws RequestObsoleteException When the sync is interrupted due to the provider + * changing. + */ + private void executeSyncRemove( + String authority, + boolean isLocal, + String mediaCollectionId, + Bundle queryArgs, + InstanceId instanceId, + @Nullable CancellationSignal cancellationSignal) + throws RequestObsoleteException, UnableToAcquireLockException { final Uri uri = getDeletedMediaUri(authority); Log.i(TAG, "Executing SyncRemove. isLocal: " + isLocal + ". authority: " + authority); + String resumeKey = + getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX + PREFS_KEY_RESUME); Trace.beginSection(traceSectionName("executeSyncRemove", isLocal)); - try (PickerDbFacade.DbWriteOperation operation = - mDbFacade.beginRemoveMediaOperation(authority)) { - executePagedSync(uri, mediaCollectionId, Arrays.asList(EXTRA_SYNC_GENERATION), - queryArgs, operation); + try { + int syncedItems = + executePagedSync( + uri, + mediaCollectionId, + List.of(EXTRA_SYNC_GENERATION), + queryArgs, + resumeKey, + OPERATION_REMOVE_MEDIA, + authority, + isLocal, + cancellationSignal); + NonUiEventLogger.logPickerRemoveMediaSyncCompletion(instanceId, MY_UID, authority, + syncedItems); } finally { Trace.endSection(); } @@ -763,7 +1011,8 @@ public class PickerSyncController { * Persist cloud provider info and send a sync request to the background thread. */ private void persistCloudProviderInfo(@NonNull CloudProviderInfo info, boolean shouldUnset) { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { setCurrentCloudProviderInfo(info); final String authority = info.authority; @@ -779,9 +1028,6 @@ public class PickerSyncController { editor.remove(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY); } - editor.putBoolean( - PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATION, isCloudProviderInfoNotEmpty); - editor.apply(); if (SdkLevel.isAtLeastT()) { @@ -798,7 +1044,22 @@ public class PickerSyncController { Log.d(TAG, "Updated cloud provider to: " + authority); - resetCachedMediaCollectionInfo(info.authority, /* isLocal */ false); + try { + resetCachedMediaCollectionInfo(info.authority, /* isLocal */ false); + } catch (UnableToAcquireLockException e) { + Log.wtf(TAG, "CLOUD_PROVIDER_LOCK is already held by this thread."); + } + + sendPickerUiRefreshNotification(); + } + } + + private void sendPickerUiRefreshNotification() { + ContentResolver contentResolver = mContext.getContentResolver(); + if (contentResolver != null) { + contentResolver.notifyChange(REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, null); + } else { + Log.d(TAG, "Couldn't notify the Picker UI to refresh"); } } @@ -816,7 +1077,7 @@ public class PickerSyncController { * Commit the latest media collection info when a sync operation is completed. */ private boolean cacheMediaCollectionInfo(@Nullable String authority, boolean isLocal, - @Nullable Bundle bundle) { + @Nullable Bundle bundle) throws UnableToAcquireLockException { if (authority == null) { Log.d(TAG, "Ignoring cache media info for null authority with bundle: " + bundle); return true; @@ -829,7 +1090,8 @@ public class PickerSyncController { cacheMediaCollectionInfoInternal(isLocal, bundle); return true; } else { - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { // Check if the media collection info belongs to the current cloud provider // authority. if (Objects.equals(authority, mCloudProviderInfo.authority)) { @@ -854,6 +1116,14 @@ public class PickerSyncController { if (bundle == null) { editor.remove(getPrefsKey(isLocal, MEDIA_COLLECTION_ID)); editor.remove(getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION)); + // Clear any resume keys for page tokens. + editor.remove( + getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX + PREFS_KEY_RESUME)); + editor.remove( + getPrefsKey(isLocal, PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX + PREFS_KEY_RESUME)); + editor.remove( + getPrefsKey( + isLocal, PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX + PREFS_KEY_RESUME)); } else { final String collectionId = bundle.getString(MEDIA_COLLECTION_ID); final long generation = bundle.getLong(LAST_MEDIA_SYNC_GENERATION); @@ -864,7 +1134,47 @@ public class PickerSyncController { editor.apply(); } - private boolean resetCachedMediaCollectionInfo(@Nullable String authority, boolean isLocal) { + /** + * Adds the given token to the saved sync preferences. + * + * @param token The token to remember. A null value will clear the preference. + * @param resumeKey The operation's key in sync preferences. + */ + private void rememberNextPageToken(@Nullable String token, String resumeKey) + throws UnableToAcquireLockException { + + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { + final SharedPreferences.Editor editor = mSyncPrefs.edit(); + if (token == null) { + Log.d(TAG, String.format("Clearing next page token for key: %s", resumeKey)); + editor.remove(resumeKey); + } else { + Log.d( + TAG, + String.format("Saving next page token: %s for key: %s", token, resumeKey)); + editor.putString(resumeKey, token); + } + editor.apply(); + } + } + + /** + * Fetches the next page token given a resume key. Returns null if no NextPage token was saved. + * + * @param resumeKey The operation's resume key. + * @return The PageToken to resume from, or {@code null} if there is no operation to resume. + */ + @Nullable + private String getPageTokenFromResumeKey(String resumeKey) throws UnableToAcquireLockException { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { + return mSyncPrefs.getString(resumeKey, /* defValue= */ null); + } + } + + private boolean resetCachedMediaCollectionInfo(@Nullable String authority, boolean isLocal) + throws UnableToAcquireLockException { return cacheMediaCollectionInfo(authority, isLocal, /* bundle */ null); } @@ -874,7 +1184,7 @@ public class PickerSyncController { final String collectionId = mSyncPrefs.getString( getPrefsKey(isLocal, MEDIA_COLLECTION_ID), /* default */ null); final long generation = mSyncPrefs.getLong( - getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION), /* default */ -1); + getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION), DEFAULT_GENERATION); bundle.putString(MEDIA_COLLECTION_ID, collectionId); bundle.putLong(LAST_MEDIA_SYNC_GENERATION, generation); @@ -882,20 +1192,37 @@ public class PickerSyncController { return bundle; } + @NonNull private Bundle getLatestMediaCollectionInfo(String authority) { - return mContext.getContentResolver().call(getMediaCollectionInfoUri(authority), - CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null, - /* extras */ null); + final InstanceId instanceId = NonUiEventLogger.generateInstanceId(); + NonUiEventLogger.logPickerGetMediaCollectionInfoStart(instanceId, MY_UID, authority); + try { + Bundle result = mContext.getContentResolver().call(getMediaCollectionInfoUri(authority), + CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null, + /* extras */ null); + return (result == null) ? (new Bundle()) : result; + } finally { + NonUiEventLogger.logPickerGetMediaCollectionInfoEnd(instanceId, MY_UID, authority); + } + } + + private Bundle getDefaultGenerationCollectionInfo(@NonNull Bundle latestCollectionInfo) { + final Bundle bundle = new Bundle(); + final String collectionId = latestCollectionInfo.getString(MEDIA_COLLECTION_ID); + bundle.putString(MEDIA_COLLECTION_ID, collectionId); + bundle.putLong(LAST_MEDIA_SYNC_GENERATION, DEFAULT_GENERATION); + return bundle; } @NonNull private SyncRequestParams getSyncRequestParams(@Nullable String authority, - boolean isLocal) throws RequestObsoleteException { + boolean isLocal) throws RequestObsoleteException, UnableToAcquireLockException { if (isLocal) { return getSyncRequestParamsInternal(authority, isLocal); } else { // Ensure that we are fetching sync request params for the current cloud provider. - synchronized (mCloudProviderLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_PROVIDER_LOCK)) { if (Objects.equals(mCloudProviderInfo.authority, authority)) { return getSyncRequestParamsInternal(authority, isLocal); } else { @@ -907,7 +1234,6 @@ public class PickerSyncController { } } - @NonNull private SyncRequestParams getSyncRequestParamsInternal(@Nullable String authority, boolean isLocal) { @@ -943,6 +1269,8 @@ public class PickerSyncController { } if (!Objects.equals(latestCollectionId, cachedCollectionId)) { + result = SyncRequestParams.forFullMediaWithReset(latestMediaCollectionInfo); + } else if (cachedGeneration == DEFAULT_GENERATION) { result = SyncRequestParams.forFullMedia(latestMediaCollectionInfo); } else if (cachedGeneration == latestGeneration) { result = SyncRequestParams.forNone(); @@ -964,42 +1292,290 @@ public class PickerSyncController { /* cancellationSignal */ null); } - private void executePagedSync(Uri uri, String expectedMediaCollectionId, - List<String> expectedHonoredArgs, Bundle queryArgs, - PickerDbFacade.DbWriteOperation dbWriteOperation) { + /** + * Creates a matching {@link PickerDbFacade.DbWriteOperation} for the given + * {@link OperationType}. + * + * @param op {@link OperationType} Which type of paged operation to begin. + * @param authority The authority string of the sync provider. + * @param albumId An {@link Nullable} AlbumId for album related operations. + * @throws IllegalArgumentException When an unexpected op type is encountered. + */ + private PickerDbFacade.DbWriteOperation beginPagedOperation( + @OperationType int op, String authority, @Nullable String albumId) + throws IllegalArgumentException { + switch (op) { + case OPERATION_ADD_MEDIA: + return mDbFacade.beginAddMediaOperation(authority); + case OPERATION_ADD_ALBUM: + Objects.requireNonNull( + albumId, "Cannot begin an AddAlbum operation without albumId"); + return mDbFacade.beginAddAlbumMediaOperation(authority, albumId); + case OPERATION_REMOVE_MEDIA: + return mDbFacade.beginRemoveMediaOperation(authority); + default: + throw new IllegalArgumentException( + "Cannot begin a paged operation without an expected operation type."); + } + } + + /** + * Executes a page-by-page sync from the provider. + * + * @param uri The uri to query for a cursor. + * @param expectedMediaCollectionId The expected media collection id. + * @param expectedHonoredArgs The arguments that are expected to be present in cursors fetched + * from the provider. + * @param queryArgs Any query arguments that are to be passed to the provider when fetching the + * cursor. + * @param resumeKey The resumable operation key. This is used to check for previously failed + * operations so they can be resumed at the last successful page, and also to save progress + * between pages. + * @param op The DbWriteOperation type. {@link OperationType} + * @param authority The authority string of the provider to sync with. + * @param cancellationSignal CancellationSignal used to abort the sync. + * @throws RequestObsoleteException When the sync is interrupted due to the provider + * changing. + * @return the total number of rows synced. + */ + private int executePagedSync( + Uri uri, + String expectedMediaCollectionId, + List<String> expectedHonoredArgs, + Bundle queryArgs, + @Nullable String resumeKey, + @OperationType int op, + String authority, + Boolean isLocal, + @Nullable CancellationSignal cancellationSignal) + throws RequestObsoleteException, UnableToAcquireLockException { + return executePagedSync( + uri, + expectedMediaCollectionId, + expectedHonoredArgs, + queryArgs, + resumeKey, + op, + authority, + isLocal, + /* albumId=*/ null, + cancellationSignal); + } + + /** + * Executes a page-by-page sync from the provider. + * + * @param uri The uri to query for a cursor. + * @param expectedMediaCollectionId The expected media collection id. + * @param expectedHonoredArgs The arguments that are expected to be present in cursors fetched + * from the provider. + * @param queryArgs Any query arguments that are to be passed to the provider when fetching the + * cursor. + * @param resumeKey The resumable operation key. This is used to check for previously failed + * operations so they can be resumed at the last successful page, and also to save progress + * between pages. + * @param op The DbWriteOperation type. {@link OperationType} + * @param authority The authority string of the provider to sync with. + * @param albumId A {@link Nullable} albumId for album related operations. + * @param cancellationSignal CancellationSignal used to abort the sync. + * @throws RequestObsoleteException When the sync is interrupted due to the provider + * changing. + * @return the total number of rows synced. + */ + private int executePagedSync( + Uri uri, + String expectedMediaCollectionId, + List<String> expectedHonoredArgs, + Bundle queryArgs, + @Nullable String resumeKey, + @OperationType int op, + String authority, + Boolean isLocal, + @Nullable String albumId, + @Nullable CancellationSignal cancellationSignal) + throws RequestObsoleteException, UnableToAcquireLockException { Trace.beginSection(traceSectionName("executePagedSync")); + try { - int cursorCount = 0; int totalRowcount = 0; // Set to check the uniqueness of tokens across pages. Set<String> tokens = new ArraySet<>(); - String nextPageToken = null; + String nextPageToken = getPageTokenFromResumeKey(resumeKey); + if (nextPageToken != null) { + Log.i( + TAG, + String.format( + "Resumable operation found for %s, resuming with page token %s", + resumeKey, nextPageToken)); + } + do { + // At the top of each loop check to see if we've received a CancellationSignal + // to stop the paged sync. + if (cancellationSignal != null && cancellationSignal.isCanceled()) { + throw new RequestObsoleteException( + "Aborting sync: cancellationSignal was received"); + } + + String updateDateTakenMs = null; if (nextPageToken != null) { queryArgs.putString(EXTRA_PAGE_TOKEN, nextPageToken); } try (Cursor cursor = query(uri, queryArgs)) { - nextPageToken = validateCursor(cursor, expectedMediaCollectionId, - expectedHonoredArgs, tokens); + nextPageToken = + validateCursor( + cursor, expectedMediaCollectionId, expectedHonoredArgs, tokens); + + try (PickerDbFacade.DbWriteOperation operation = + beginPagedOperation(op, authority, albumId)) { + int writeCount = operation.execute(cursor); + + if (!isLocal) { + // Ensure the cloud provider hasn't change out from underneath the + // running sync. If it has, we need to stop syncing. + String currentCloudProvider = getCloudProviderWithTimeout(); + if (TextUtils.isEmpty(currentCloudProvider) + || !currentCloudProvider.equals(authority)) { + + throw new RequestObsoleteException( + String.format( + "Aborting sync: the CloudProvider seems to have" + + " changed mid-sync. Old: %s Current: %s", + authority, currentCloudProvider)); + } + } + + operation.setSuccess(); + totalRowcount += writeCount; + + if (cursor.getCount() > 0) { + // Before the cursor is closed pull the date taken ms for the first row. + updateDateTakenMs = getFirstDateTakenMsInCursor(cursor); + + // If the cursor count is not null and the date taken field is not + // present in the cursor, fallback on the operation to provide the date + // taken. + if (updateDateTakenMs == null) { + updateDateTakenMs = getFirstDateTakenMsFromOperation(operation); + } + } + } + } catch (IllegalArgumentException ex) { + Log.e(TAG, String.format("Failed to open DbWriteOperation for op: %d", op), ex); + return -1; + } + + // Keep track of the next page token in case this operation crashes and is + // later resumed. + rememberNextPageToken(nextPageToken, resumeKey); - int writeCount = dbWriteOperation.execute(cursor); + // Emit notification that new data has arrived in the database. + if (updateDateTakenMs != null) { + Uri notification = buildNotificationUri(op, albumId, updateDateTakenMs); - totalRowcount += writeCount; - cursorCount += cursor.getCount(); + if (notification != null) { + mContext.getContentResolver() + .notifyChange(/* itemUri= */ notification, /* observer= */ null); + } } + } while (nextPageToken != null); - dbWriteOperation.setSuccess(); - Log.i(TAG, "Paged sync successful. QueryArgs: " + queryArgs + ". Result count: " - + totalRowcount + ". Cursor count: " + cursorCount); + Log.i( + TAG, + "Paged sync successful. QueryArgs: " + + queryArgs + + " Total Rows: " + + totalRowcount); + return totalRowcount; } finally { Trace.endSection(); } } /** + * Extracts the {@link MediaColumns.DATE_TAKEN_MILLIS} from the first row in the cursor. + * + * @param cursor The cursor to read from. + * @return Either the column value if it exists, or {@code null} if it doesn't. + */ + @Nullable + private String getFirstDateTakenMsInCursor(Cursor cursor) { + if (cursor.moveToFirst()) { + return getCursorString(cursor, MediaColumns.DATE_TAKEN_MILLIS); + } + return null; + } + + /** + * Extracts the first row's date taken from the operation. Note that all functions may not + * implement this method. + */ + private String getFirstDateTakenMsFromOperation(PickerDbFacade.DbWriteOperation op) { + final long firstDateTakenMillis = op.getFirstDateTakenMillis(); + + return firstDateTakenMillis == Long.MIN_VALUE + ? null + : Long.toString(firstDateTakenMillis); + } + + /** + * Assembles a ContentObserver notification uri for the given operation. + * + * @param op {@link OperationType} the operation to notify has completed. + * @param albumId An optional album id if this is an album based operation. + * @param dateTakenMs The notification data; the {@link MediaColumns.DATE_TAKEN_MILLIS} of the + * first row updated. + * @return the assembled notification uri. + */ + @Nullable + private Uri buildNotificationUri( + @NonNull @OperationType int op, + @Nullable String albumId, + @Nullable String dateTakenMs) { + + Objects.requireNonNull( + dateTakenMs, "Cannot notify subscribers without a date taken timestamp."); + + // base: content://media/picker_internal/ + Uri.Builder builder = PICKER_INTERNAL_URI.buildUpon().appendPath(UPDATE); + + switch (op) { + case OPERATION_ADD_MEDIA: + // content://media/picker_internal/update/media + builder.appendPath(MEDIA); + break; + case OPERATION_ADD_ALBUM: + // content://media/picker_internal/update/album_content/${albumId} + builder.appendPath(ALBUM_CONTENT); + builder.appendPath(albumId); + break; + case OPERATION_REMOVE_MEDIA: + if (albumId != null) { + // content://media/picker_internal/update/album_content/${albumId} + builder.appendPath(ALBUM_CONTENT); + builder.appendPath(albumId); + } else { + // content://media/picker_internal/update/media + builder.appendPath(MEDIA); + } + break; + default: + Log.w( + TAG, + String.format( + "Requested operation (%d) is not supported for notifications.", + op)); + return null; + } + + builder.appendPath(dateTakenMs); + return builder.build(); + } + + /** * Get the default {@link CloudProviderInfo} at {@link PickerSyncController} construction */ @VisibleForTesting @@ -1094,16 +1670,21 @@ public class PickerSyncController { final long syncGeneration; // Only valid for SYNC_TYPE_[INCREMENTAL|FULL] final Bundle latestMediaCollectionInfo; + // Only valid for sync triggered by opening photopicker activity. + // Not valid for proactive syncs. + final int mPageSize; SyncRequestParams(@SyncType int syncType) { - this(syncType, /* syncGeneration */ 0, /* latestMediaCollectionInfo */ null); + this(syncType, /* syncGeneration */ 0, /* latestMediaCollectionInfo */ null, + /*pageSize */ PAGE_SIZE); } SyncRequestParams(@SyncType int syncType, long syncGeneration, - Bundle latestMediaCollectionInfo) { + Bundle latestMediaCollectionInfo, int pageSize) { this.syncType = syncType; this.syncGeneration = syncGeneration; this.latestMediaCollectionInfo = latestMediaCollectionInfo; + this.mPageSize = pageSize; } String getMediaCollectionId() { @@ -1118,20 +1699,26 @@ public class PickerSyncController { return SYNC_REQUEST_MEDIA_RESET; } - static SyncRequestParams forFullMedia(Bundle latestMediaCollectionInfo) { + static SyncRequestParams forFullMediaWithReset(@NonNull Bundle latestMediaCollectionInfo) { + return new SyncRequestParams(SYNC_TYPE_MEDIA_FULL_WITH_RESET, /* generation */ 0, + latestMediaCollectionInfo, /*pageSize */ PAGE_SIZE); + } + + static SyncRequestParams forFullMedia(@NonNull Bundle latestMediaCollectionInfo) { return new SyncRequestParams(SYNC_TYPE_MEDIA_FULL, /* generation */ 0, - latestMediaCollectionInfo); + latestMediaCollectionInfo, /*pageSize */ PAGE_SIZE); } static SyncRequestParams forIncremental(long generation, Bundle latestMediaCollectionInfo) { return new SyncRequestParams(SYNC_TYPE_MEDIA_INCREMENTAL, generation, - latestMediaCollectionInfo); + latestMediaCollectionInfo, /*pageSize */ PAGE_SIZE); } @Override public String toString() { return "SyncRequestParams{type=" + syncTypeToString(syncType) - + ", gen=" + syncGeneration + ", latest=" + latestMediaCollectionInfo + '}'; + + ", gen=" + syncGeneration + ", latest=" + latestMediaCollectionInfo + + ", pageSize=" + mPageSize + '}'; } } @@ -1145,6 +1732,8 @@ public class PickerSyncController { return "MEDIA_FULL"; case SYNC_TYPE_MEDIA_RESET: return "MEDIA_RESET"; + case SYNC_TYPE_MEDIA_FULL_WITH_RESET: + return "MEDIA_FULL_WITH_RESET"; default: return "Unknown"; } @@ -1153,4 +1742,23 @@ public class PickerSyncController { private static boolean isCloudProviderUnset(@Nullable String lastProviderAuthority) { return Objects.equals(lastProviderAuthority, PREFS_VALUE_CLOUD_PROVIDER_UNSET); } + + /** + * Print the {@link PickerSyncController} state into the given stream. + */ + public void dump(PrintWriter writer) { + writer.println("Picker sync controller state:"); + + writer.println(" mLocalProvider=" + getLocalProvider()); + writer.println(" mCloudProviderInfo=" + getCurrentCloudProviderInfo()); + writer.println(" allAvailableCloudProviders=" + + CloudProviderUtils.getAllAvailableCloudProviders(mContext, mConfigStore)); + + writer.println(" cachedAuthority=" + + mUserPrefs.getString(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, /* defValue */ null)); + writer.println(" cachedLocalMediaCollectionInfo=" + + getCachedMediaCollectionInfo(/* isLocal */ true)); + writer.println(" cachedCloudMediaCollectionInfo=" + + getCachedMediaCollectionInfo(/* isLocal */ false)); + } } diff --git a/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java b/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java index 661345bdb..deefc1b85 100644 --- a/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java +++ b/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java @@ -27,9 +27,11 @@ import android.app.AlertDialog; import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.Context; +import android.content.DialogInterface; import android.net.Uri; import android.os.Looper; import android.util.Log; +import android.widget.Button; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -42,12 +44,18 @@ import androidx.tracing.Trace; import com.android.providers.media.R; import java.io.FileNotFoundException; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; /** * Responsible for "preloading" selected media items including showing the appropriate UI @@ -56,9 +64,10 @@ import java.util.concurrent.atomic.AtomicInteger; * @see #preload(Context, List) */ class SelectedMediaPreloader { + private static final long TIMEOUT_IN_SECONDS = 4L; private static final String TRACE_SECTION_NAME = "preload-selected-media"; private static final String TAG = "SelectedMediaPreloader"; - private static final boolean DEBUG = false; + private static final boolean DEBUG = true; @Nullable private static volatile Executor sExecutor; @@ -66,6 +75,7 @@ class SelectedMediaPreloader { @NonNull private final List<Uri> mItems; private final int mCount; + private boolean mIsPreloadingCancelled = false; @NonNull private final AtomicInteger mFinishedCount = new AtomicInteger(0); @NonNull @@ -73,7 +83,14 @@ class SelectedMediaPreloader { @NonNull private final MutableLiveData<Boolean> mIsFinishedLiveData = new MutableLiveData<>(false); @NonNull + private static final MutableLiveData<Boolean> mIsPreloadingCancelledLiveData = + new MutableLiveData<>(false); + @NonNull + private final MutableLiveData<List<Integer>> mUnavailableMediaIndexes = + new MutableLiveData<>(new ArrayList<>()); + @NonNull private final ContentResolver mContentResolver; + private List<Integer> mSuccessfullyPreloadedMediaIndexes = new ArrayList<>(); /** * Creates, start and eventually returns a new {@link SelectedMediaPreloader} instance. @@ -92,6 +109,7 @@ class SelectedMediaPreloader { // Make a copy of the list. final List<Uri> items = new ArrayList<>(requireNonNull(selectedMedia)); final int count = items.size(); + mIsPreloadingCancelledLiveData.setValue(false); Log.d(TAG, "preload() " + count + " items"); if (DEBUG) { @@ -107,24 +125,24 @@ class SelectedMediaPreloader { Trace.beginAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode()); - final var dialog = createProgressDialog(activity, items); + final var dialog = createProgressDialog(activity, items, context); preloader.mIsFinishedLiveData.observeForever(new Observer<>() { @Override public void onChanged(Boolean isFinished) { if (isFinished) { preloader.mIsFinishedLiveData.removeObserver(this); - dialog.dismiss(); - Trace.endAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode()); } } }); + preloader.mFinishedCountLiveData.observeForever(new Observer<>() { @Override public void onChanged(Integer finishedCount) { if (finishedCount == count) { preloader.mFinishedCountLiveData.removeObserver(this); + dialog.dismiss(); } // "X of Y ready" final String message = context.getString( @@ -133,9 +151,31 @@ class SelectedMediaPreloader { } }); + mIsPreloadingCancelledLiveData.observeForever(new Observer<>() { + @Override + public void onChanged(Boolean isPreloadingCancelled) { + if (isPreloadingCancelled) { + preloader.mIsPreloadingCancelled = true; + mIsPreloadingCancelledLiveData.removeObserver(this); + List<Integer> unsuccessfullyPreloadedMediaIndexes = new ArrayList<>(); + for (int index = 0; index < preloader.mItems.size(); index++) { + if (!preloader.mSuccessfullyPreloadedMediaIndexes.contains(index)) { + unsuccessfullyPreloadedMediaIndexes.add(index); + } + } + // this extra "-1" element indicates that preloading has been cancelled by + // the user + unsuccessfullyPreloadedMediaIndexes.add(-1); + preloader.mUnavailableMediaIndexes.setValue( + unsuccessfullyPreloadedMediaIndexes); + preloader.mIsFinishedLiveData.setValue(false); + preloader.mFinishedCountLiveData.setValue(preloader.mItems.size()); + } + } + }); + ensureExecutor(); preloader.start(sExecutor); - return preloader; } @@ -155,27 +195,49 @@ class SelectedMediaPreloader { return mIsFinishedLiveData; } + @NonNull + LiveData<List<Integer>> getUnavailableMediaIndexes() { + return mUnavailableMediaIndexes; + } + /** * This method is intentionally {@code private}: clients should use static * {@link #preload(Context, List)} method. */ @UiThread private void start(@NonNull Executor executor) { - for (var item : mItems) { + List<Integer> unavailableMediaIndexes = new ArrayList<>(); + for (int index = 0; index < mItems.size(); index++) { + int currIndex = index; // Off-loading to an Executor (presumable backed up by a thread pool) executor.execute(new Runnable() { @Override public void run() { - openFileDescriptor(item); + boolean isOpenedSuccessfully = false; + if (!mIsPreloadingCancelled) { + isOpenedSuccessfully = openFileDescriptor(mItems.get(currIndex)); + } + + if (!isOpenedSuccessfully) { + unavailableMediaIndexes.add(currIndex); + } else { + mSuccessfullyPreloadedMediaIndexes.add(currIndex); + } final int preloadedCount = mFinishedCount.incrementAndGet(); if (DEBUG) { Log.d(TAG, "Preloaded " + preloadedCount + " (of " + mCount + ") items"); } - if (preloadedCount == mCount) { + + if (preloadedCount == mCount && !mIsPreloadingCancelled) { // Don't need to "synchronize" here: mCount is our final value for // preloadedCount, it won't be changing anymore. - mIsFinishedLiveData.postValue(true); + if (unavailableMediaIndexes.size() == 0) { + mIsFinishedLiveData.postValue(true); + } else { + mUnavailableMediaIndexes.postValue(unavailableMediaIndexes); + mIsFinishedLiveData.postValue(false); + } } // In order to prevent race conditions where we may "post" a lower value after @@ -190,7 +252,8 @@ class SelectedMediaPreloader { } @Nullable - private void openFileDescriptor(@NonNull Uri uri) { + private Boolean openFileDescriptor(@NonNull Uri uri) { + AtomicReference<Boolean> isOpenedSuccessfully = new AtomicReference<>(true); long start = 0; if (DEBUG) { Log.d(TAG, "openFileDescriptor() START, " + Thread.currentThread() + ", " + uri); @@ -198,10 +261,24 @@ class SelectedMediaPreloader { } Trace.beginSection("Preloader.openFd"); + + CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { + try { + mContentResolver.openAssetFileDescriptor(uri, "r").close(); + } catch (FileNotFoundException e) { + isOpenedSuccessfully.set(false); + Log.w(TAG, "Could not open FileDescriptor for " + uri, e); + } catch (IOException e) { + Log.w(TAG, "Failed to preload media file ", e); + } + }); + try { - mContentResolver.openAssetFileDescriptor(uri, "r"); - } catch (FileNotFoundException e) { - Log.w(TAG, "Could not open FileDescriptor for " + uri, e); + future.get(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + return isOpenedSuccessfully.get(); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, "Could not preload the media item ", e); } finally { Trace.endSection(); @@ -211,22 +288,43 @@ class SelectedMediaPreloader { + ", " + uri); } } + + return isOpenedSuccessfully.get(); } @NonNull private static AlertDialog createProgressDialog( - @NonNull Activity activity, @NonNull List<Uri> selectedMedia) { - return ProgressDialog.show(activity, - /* tile */ "Preparing your selected media", - /* message */ "0 of " + selectedMedia.size() + " ready.", - /* indeterminate */ true); + @NonNull Activity activity, @NonNull List<Uri> selectedMedia, Context context) { + ProgressDialog dialog = new ProgressDialog(activity, + R.style.SelectedMediaPreloaderDialogTheme); + dialog.setTitle(/* title */ context.getString(R.string.preloading_dialog_title)); + dialog.setMessage(/* message */ context.getString( + R.string.preloading_progress_message, 0, selectedMedia.size())); + dialog.setIndeterminate(/* indeterminate */ true); + dialog.setCancelable(false); + + dialog.setButton(DialogInterface.BUTTON_NEGATIVE, + context.getString(R.string.preloading_cancel_button), (dialog1, which) -> { + mIsPreloadingCancelledLiveData.setValue(true); + }); + dialog.create(); + + Button cancelButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); + if (cancelButton != null) { + cancelButton.setTextAppearance(R.style.ProgressDialogCancelButtonStyle); + cancelButton.setAllCaps(false); + } + + dialog.show(); + + return dialog; } private static void ensureExecutor() { if (sExecutor == null) { synchronized (SelectedMediaPreloader.class) { if (sExecutor == null) { - final ThreadFactory threadFactory = new ThreadFactory() { + sExecutor = Executors.newFixedThreadPool(2, new ThreadFactory() { final AtomicInteger mCount = new AtomicInteger(1); @@ -250,8 +348,7 @@ class SelectedMediaPreloader { } }; } - }; - sExecutor = Executors.newCachedThreadPool(threadFactory); + }); } } } diff --git a/src/com/android/providers/media/photopicker/TEST_MAPPING b/src/com/android/providers/media/photopicker/TEST_MAPPING new file mode 100644 index 000000000..53545d87f --- /dev/null +++ b/src/com/android/providers/media/photopicker/TEST_MAPPING @@ -0,0 +1,22 @@ +{ + "mainline-presubmit": [ + { + "name": "CtsPhotoPickerTest[com.google.android.mediaprovider.apex]", + "options": [ + { + "exclude-annotation": "androidx.test.filters.LargeTest" + } + ] + } + ], + "presubmit": [ + { + "name": "CtsPhotoPickerTest", + "options": [ + { + "exclude-annotation": "androidx.test.filters.LargeTest" + } + ] + } + ] +} diff --git a/src/com/android/providers/media/photopicker/data/CloudProviderQueryExtras.java b/src/com/android/providers/media/photopicker/data/CloudProviderQueryExtras.java index f05d3a487..4dcd6f77d 100644 --- a/src/com/android/providers/media/photopicker/data/CloudProviderQueryExtras.java +++ b/src/com/android/providers/media/photopicker/data/CloudProviderQueryExtras.java @@ -17,8 +17,13 @@ package com.android.providers.media.photopicker.data; import static android.content.ContentResolver.QUERY_ARG_LIMIT; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_DATE_TAKEN_BEFORE_MS; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_LOCAL_ID_SELECTION; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_ROW_ID; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.BOOLEAN_DEFAULT; +import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.INT_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LIMIT_DEFAULT; +import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LIST_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LONG_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.STRING_ARRAY_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.STRING_DEFAULT; @@ -31,6 +36,8 @@ import android.provider.MediaStore; import com.android.providers.media.photopicker.PickerDataLayer; +import java.util.List; + /** * Represents the {@link CloudMediaProviderContract} extra filters from a {@link Bundle}. */ @@ -44,6 +51,13 @@ public class CloudProviderQueryExtras { private final boolean mIsFavorite; private final boolean mIsVideo; private final boolean mIsLocalOnly; + private final int mPageSize; + private final long mDateTakenBeforeMs; + private final int mRowId; + + private final List<Integer> mLocalIdSelection; + + private String mPageToken; private CloudProviderQueryExtras() { mAlbumId = STRING_DEFAULT; @@ -55,11 +69,17 @@ public class CloudProviderQueryExtras { mIsFavorite = BOOLEAN_DEFAULT; mIsVideo = BOOLEAN_DEFAULT; mIsLocalOnly = BOOLEAN_DEFAULT; + mPageSize = INT_DEFAULT; + mDateTakenBeforeMs = Long.MIN_VALUE; + mRowId = INT_DEFAULT; + mLocalIdSelection = LIST_DEFAULT; + mPageToken = STRING_DEFAULT; } private CloudProviderQueryExtras(String albumId, String albumAuthority, String[] mimeTypes, long sizeBytes, long generation, int limit, boolean isFavorite, boolean isVideo, - boolean isLocalOnly) { + boolean isLocalOnly, int pageSize, long dateTakenBeforeMs, int rowId, + List<Integer> localIdSelection, String pageToken) { mAlbumId = albumId; mAlbumAuthority = albumAuthority; mMimeTypes = mimeTypes; @@ -69,6 +89,11 @@ public class CloudProviderQueryExtras { mIsFavorite = isFavorite; mIsVideo = isVideo; mIsLocalOnly = isLocalOnly; + mPageSize = pageSize; + mDateTakenBeforeMs = dateTakenBeforeMs; + mRowId = rowId; + mLocalIdSelection = localIdSelection; + mPageToken = pageToken; } /** @@ -88,14 +113,21 @@ public class CloudProviderQueryExtras { final long generation = LONG_DEFAULT; final int limit = bundle.getInt(QUERY_ARG_LIMIT, LIMIT_DEFAULT); - final boolean isFavorite = AlbumColumns.ALBUM_ID_FAVORITES.equals(albumId); - final boolean isVideo = AlbumColumns.ALBUM_ID_VIDEOS.equals(albumId); + final boolean isFavorite = isFavorite(albumId); + final boolean isVideo = isVideo(albumId); final boolean isLocalOnly = bundle.getBoolean(PickerDataLayer.QUERY_ARG_LOCAL_ONLY, BOOLEAN_DEFAULT); + final int pageSize = INT_DEFAULT; + final long dateTakenBeforeMs = bundle.getLong(QUERY_DATE_TAKEN_BEFORE_MS, Long.MIN_VALUE); + final int rowId = bundle.getInt(QUERY_ROW_ID, INT_DEFAULT); + final List<Integer> localIdSelection = bundle.getIntegerArrayList(QUERY_LOCAL_ID_SELECTION); + final String pageToken = bundle.getString( + CloudMediaProviderContract.EXTRA_PAGE_TOKEN, STRING_DEFAULT); return new CloudProviderQueryExtras(albumId, albumAuthority, mimeTypes, sizeBytes, - generation, limit, isFavorite, isVideo, isLocalOnly); + generation, limit, isFavorite, isVideo, isLocalOnly, pageSize, dateTakenBeforeMs, + rowId, localIdSelection, pageToken); } public static CloudProviderQueryExtras fromCloudMediaBundle(Bundle bundle) { @@ -117,9 +149,17 @@ public class CloudProviderQueryExtras { final boolean isFavorite = BOOLEAN_DEFAULT; final boolean isVideo = BOOLEAN_DEFAULT; final boolean isLocalOnly = BOOLEAN_DEFAULT; + final long dateTakenBeforeMs = bundle.getLong(QUERY_DATE_TAKEN_BEFORE_MS, Long.MIN_VALUE); + final int rowId = bundle.getInt(QUERY_ROW_ID, INT_DEFAULT); + + final int pageSize = bundle.getInt(CloudMediaProviderContract.EXTRA_PAGE_SIZE, INT_DEFAULT); + final List<Integer> localIdSelection = bundle.getIntegerArrayList(QUERY_LOCAL_ID_SELECTION); + final String pageToken = bundle.getString( + CloudMediaProviderContract.EXTRA_PAGE_TOKEN, STRING_DEFAULT); return new CloudProviderQueryExtras(albumId, albumAuthority, mimeTypes, sizeBytes, - generation, limit, isFavorite, isVideo, isLocalOnly); + generation, limit, isFavorite, isVideo, isLocalOnly, pageSize, dateTakenBeforeMs, + rowId, localIdSelection, pageToken); } public PickerDbFacade.QueryFilter toQueryFilter() { @@ -130,6 +170,11 @@ public class CloudProviderQueryExtras { qfb.setIsVideo(mIsVideo); qfb.setAlbumId(mAlbumId); qfb.setIsLocalOnly(mIsLocalOnly); + qfb.setDateTakenBeforeMs(mDateTakenBeforeMs); + qfb.setId(mRowId); + qfb.setLocalIdSelection(mLocalIdSelection); + qfb.setPageSize(mPageSize); + qfb.setPageToken(mPageToken); return qfb.build(); } @@ -142,6 +187,28 @@ public class CloudProviderQueryExtras { return extras; } + /** + * Checks if the query is for a merged album type. + */ + public boolean isMergedAlbum() { + return mIsFavorite || mIsVideo; + } + + private static boolean isFavorite(String albumId) { + return AlbumColumns.ALBUM_ID_FAVORITES.equals(albumId); + } + + private static boolean isVideo(String albumId) { + return AlbumColumns.ALBUM_ID_VIDEOS.equals(albumId); + } + + /** + * Checks if the given albumID belongs to a merged album type. + */ + public static boolean isMergedAlbum(String albumId) { + return isFavorite(albumId) || isVideo(albumId); + } + public String getAlbumId() { return mAlbumId; } @@ -173,4 +240,12 @@ public class CloudProviderQueryExtras { public boolean isLocalOnly() { return mIsLocalOnly; } + + public int getPageSize() { + return mPageSize; + } + + public String getPageToken() { + return mPageToken; + } } diff --git a/src/com/android/providers/media/photopicker/data/ExternalDbFacade.java b/src/com/android/providers/media/photopicker/data/ExternalDbFacade.java index d4e7fb1a7..c4361ba61 100644 --- a/src/com/android/providers/media/photopicker/data/ExternalDbFacade.java +++ b/src/com/android/providers/media/photopicker/data/ExternalDbFacade.java @@ -23,9 +23,12 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS; import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID; import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo; +import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.INT_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LONG_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.addMimeTypesToQueryBuilderAndSelectionArgs; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong; @@ -117,6 +120,13 @@ public class ExternalDbFacade { private static final String WHERE_RELATIVE_PATH = MediaStore.MediaColumns.RELATIVE_PATH + " LIKE ?"; + private static final String WHERE_DATE_TAKEN_MILLIS_BEFORE = + String.format("(%s < CAST(? AS INT) OR (%s = CAST(? AS INT) AND %s < CAST(? AS INT)))", + CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS, + CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS, + MediaColumns._ID); + + /* Include any directory named exactly {@link Environment.DIRECTORY_SCREENSHOTS} * and its child directories. */ private static final String WHERE_RELATIVE_PATH_IS_SCREENSHOT_DIR = @@ -275,7 +285,8 @@ public class ExternalDbFacade { /* having */ null, /* orderBy */ null); }); - cursor.setExtras(getCursorExtras(generation, /* albumId */ null)); + cursor.setExtras(getCursorExtras(generation, /* albumId */ null, /*pageSize*/ -1, + /*pageToken*/ null)); return cursor; } @@ -283,27 +294,85 @@ public class ExternalDbFacade { * Returns all items from the files table where {@link MediaColumns#GENERATION_MODIFIED} * is greater than {@code generation}. */ - public Cursor queryMedia(long generation, String albumId, String[] mimeTypes) { + public Cursor queryMedia(long generation, String albumId, String[] mimeTypes, int pageSize, + String pageToken) { final List<String> selectionArgs = new ArrayList<>(); - final String orderBy = CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS + " DESC"; + final String orderBy = getOrderByClause(); + + Log.d(TAG, "Token received for queryMedia = " + pageToken); final Cursor cursor = mDatabaseHelper.runWithTransaction(db -> { - SQLiteQueryBuilder qb = createMediaQueryBuilder(); - qb.appendWhereStandalone(WHERE_GREATER_GENERATION); - selectionArgs.add(String.valueOf(generation)); + SQLiteQueryBuilder qb = createMediaQueryBuilder(); + qb.appendWhereStandalone(WHERE_GREATER_GENERATION); + selectionArgs.add(String.valueOf(generation)); + + if (pageToken != null) { + String[] lastMedia = parsePageToken(pageToken); + if (lastMedia != null) { + qb.appendWhereStandalone(getDateTakenWhereClause()); + addSelectionArgsForWhereClause(lastMedia, selectionArgs); + } + } - selectionArgs.addAll(appendWhere(qb, albumId, mimeTypes)); + selectionArgs.addAll(appendWhere(qb, albumId, mimeTypes)); - return qb.query(db, PROJECTION_MEDIA_COLUMNS, /* select */ null, - selectionArgs.toArray(new String[selectionArgs.size()]), /* groupBy */ null, - /* having */ null, orderBy); - }); + return qb.query(db, PROJECTION_MEDIA_COLUMNS, /* select */ null, + selectionArgs.toArray(new String[selectionArgs.size()]), /* groupBy */ null, + /* having */ null, orderBy, String.valueOf(pageSize)); + }); - cursor.setExtras(getCursorExtras(generation, albumId)); + String nextPageToken = null; + if (cursor.getCount() > 0 && pageSize != INT_DEFAULT) { + nextPageToken = setPageToken(cursor); + + } + cursor.setExtras(getCursorExtras(generation, albumId, pageSize, nextPageToken)); return cursor; } - private Bundle getCursorExtras(long generation, String albumId) { + private static void addSelectionArgsForWhereClause(String[] lastMedia, + List<String> selectionArgs) { + selectionArgs.add(lastMedia[0]); + selectionArgs.add(lastMedia[0]); + selectionArgs.add(lastMedia[1]); + } + + private static String[] parsePageToken(String pageToken) { + String[] lastMedia = pageToken.split("\\|"); + + if (lastMedia.length != 2) { + Log.w(TAG, "Error parsing token in queryMedia."); + return null; + } + return lastMedia; + } + + private static String getDateTakenWhereClause() { + return CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS + " IS NOT NULL AND " + + WHERE_DATE_TAKEN_MILLIS_BEFORE; + } + + private static String getOrderByClause() { + return CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS + " DESC," + + CloudMediaProviderContract.MediaColumns.ID + " DESC"; + } + + + private String setPageToken(Cursor mediaList) { + String token = null; + if (mediaList.moveToLast()) { + String timeTakenMillis = getCursorString(mediaList, + CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS); + String lastItemRowId = getCursorString(mediaList, + CloudMediaProviderContract.MediaColumns.ID); + token = timeTakenMillis + "|" + lastItemRowId; + mediaList.moveToFirst(); + } + return token; + } + + private Bundle getCursorExtras(long generation, String albumId, int pageSize, + String pageToken) { final Bundle bundle = new Bundle(); final ArrayList<String> honoredArgs = new ArrayList<>(); @@ -314,7 +383,18 @@ public class ExternalDbFacade { honoredArgs.add(EXTRA_ALBUM_ID); } + if (pageSize > INT_DEFAULT) { + honoredArgs.add(EXTRA_PAGE_SIZE); + } + + if (pageToken != null) { + honoredArgs.add(EXTRA_PAGE_TOKEN); + } + bundle.putString(EXTRA_MEDIA_COLLECTION_ID, getMediaCollectionId()); + if (pageToken != null) { + bundle.putString(EXTRA_PAGE_TOKEN, pageToken); + } bundle.putStringArrayList(EXTRA_HONORED_ARGS, honoredArgs); return bundle; @@ -472,6 +552,10 @@ public class ExternalDbFacade { qb.appendWhereStandalone(WHERE_NOT_TRASHED); qb.appendWhereStandalone(WHERE_NOT_PENDING); + // the file is corrupted if both datetaken and takenmodified are null. + // hence exclude those files. + qb.appendWhereStandalone(getDateTakenOrDateModifiedNonNull()); + String[] volumes = getVolumeList(); if (volumes.length > 0) { qb.appendWhereStandalone(buildWhereVolumeIn(volumes)); @@ -480,6 +564,11 @@ public class ExternalDbFacade { return qb; } + private CharSequence getDateTakenOrDateModifiedNonNull() { + return MediaColumns.DATE_TAKEN + " IS NOT NULL OR " + + MediaColumns.DATE_MODIFIED + " IS NOT NULL"; + } + private String buildWhereVolumeIn(String[] volumes) { return String.format(WHERE_VOLUME_IN_PREFIX, bindList((Object[]) volumes)); } diff --git a/src/com/android/providers/media/photopicker/data/ItemsProvider.java b/src/com/android/providers/media/photopicker/data/ItemsProvider.java index 84a5356d2..6e583956d 100644 --- a/src/com/android/providers/media/photopicker/data/ItemsProvider.java +++ b/src/com/android/providers/media/photopicker/data/ItemsProvider.java @@ -18,21 +18,27 @@ package com.android.providers.media.photopicker.data; import static android.content.ContentResolver.QUERY_ARG_LIMIT; import static android.database.DatabaseUtils.dumpCursorToString; -import static android.widget.Toast.LENGTH_LONG; +import static android.provider.MediaStore.AUTHORITY; +import static android.provider.MediaStore.MediaColumns.DATA; +import static com.android.providers.media.MediaGrants.FILE_ID_COLUMN; import static com.android.providers.media.PickerUriResolver.PICKER_INTERNAL_URI; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_DATE_TAKEN_BEFORE_MS; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_LOCAL_ID_SELECTION; +import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_ROW_ID; +import static com.android.providers.media.photopicker.util.CloudProviderUtils.sendInitPhotoPickerDataNotification; +import static com.android.providers.media.util.FileUtils.getContentUriForPath; import android.content.ContentProvider; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager.NameNotFoundException; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; +import android.os.CancellationSignal; import android.os.RemoteException; import android.os.Trace; import android.os.UserHandle; @@ -40,32 +46,32 @@ import android.provider.CloudMediaProviderContract.AlbumColumns; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.PickerUriResolver; -import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.model.Category; import com.android.providers.media.photopicker.data.model.UserId; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.Objects; /** * Provides image and video items from {@link MediaStore} collection to the Photo Picker. */ public class ItemsProvider { private static final String TAG = ItemsProvider.class.getSimpleName(); - private static final boolean DEBUG = false; + private static final boolean DEBUG = true; private static final boolean DEBUG_DUMP_CURSORS = false; private final Context mContext; public ItemsProvider(Context context) { mContext = context; - ensureNotificationHandler(context); } private static final Uri URI_MEDIA_ALL; @@ -73,6 +79,10 @@ public class ItemsProvider { private static final Uri URI_ALBUMS_ALL; private static final Uri URI_ALBUMS_LOCAL; + private static final String MEDIA_GRANTS_URI_PATH = "content://media/media_grants"; + public static final String EXTRA_MIME_TYPE_SELECTION = "media_grant_mime_type_selection"; + + static { final Uri media = PICKER_INTERNAL_URI.buildUpon() .appendPath(PickerUriResolver.MEDIA_PATH).build(); @@ -92,9 +102,10 @@ public class ItemsProvider { * <p> * By default, the returned {@link Cursor} sorts by latest date taken. * - * @param category the category of items to return. May be cloud, local or merged albums like - * favorites or videos. - * @param limit the limit of number of items to return. + * @param category the category of items to return. May be cloud, local or merged albums like + * favorites or videos. + * @param pagingParameters parameters to represent the page for which the items need to be + * returned. * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are * scanned by {@link MediaStore}. * @param userId the {@link UserId} of the user to get items as. @@ -106,21 +117,15 @@ public class ItemsProvider { * contains {@link android.provider.CloudMediaProviderContract.MediaColumns} */ @Nullable - public Cursor getAllItems(Category category, int limit, @Nullable String[] mimeTypes, - @Nullable UserId userId) throws IllegalArgumentException { - if (DEBUG) { - Log.d(TAG, "getAllItems() userId=" + userId + " cat=" + category - + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); - } - + public Cursor getAllItems(Category category, PaginationParameters pagingParameters, + @Nullable String[] mimeTypes, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { Trace.beginSection("ItemsProvider.getAllItems"); try { - sNotificationHandler.onLoadingStarted(); - - return queryMedia(URI_MEDIA_ALL, limit, mimeTypes, category, userId); + return queryMedia(URI_MEDIA_ALL, pagingParameters, mimeTypes, category, userId, + cancellationSignal); } finally { - sNotificationHandler.onLoadingFinished(); Trace.endSection(); } } @@ -132,9 +137,10 @@ public class ItemsProvider { * <p> * By default, the returned {@link Cursor} sorts by latest date taken. * - * @param category the category of items to return. May be local or merged albums like - * favorites or videos. - * @param limit the limit of number of items to return. + * @param category the category of items to return. May be local or merged albums like + * favorites or videos. + * @param pagingParameters parameters to represent the page for which the items need to be + * returned. * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are * scanned by {@link MediaStore}. * @param userId the {@link UserId} of the user to get items as. @@ -149,23 +155,40 @@ public class ItemsProvider { * this method is called with a non-local album. */ @Nullable - public Cursor getLocalItems(Category category, int limit, @Nullable String[] mimeTypes, - @Nullable UserId userId) throws IllegalArgumentException { - if (DEBUG) { - Log.d(TAG, "getLocalItems() userId=" + userId + " cat=" + category - + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); - } - + public Cursor getLocalItems(Category category, PaginationParameters pagingParameters, + @Nullable String[] mimeTypes, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { Trace.beginSection("ItemsProvider.getLocalItems"); try { - return queryMedia(URI_MEDIA_LOCAL, limit, mimeTypes, category, userId); + return queryMedia(URI_MEDIA_LOCAL, pagingParameters, mimeTypes, category, userId, + cancellationSignal); } finally { Trace.endSection(); } } /** + * Gets cursor for items corresponding to the ids passed as an argument. + * + * @param category the category of items to return. + * @param mimeTypes the mime type of item. {@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} + * @param localIdSelection list of ids for which the item objects are required + */ + public Cursor getLocalItemsForSelection(Category category, + @NonNull List<Integer> localIdSelection, + @Nullable String[] mimeTypes, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { + Objects.requireNonNull(localIdSelection); + return queryMedia(URI_MEDIA_LOCAL, new PaginationParameters(), mimeTypes, category, userId, + localIdSelection, cancellationSignal); + } + + /** * Returns a {@link Cursor} to all non-empty categories in which images/videos are categorised. * This includes: * * A constant list of local categories for on-device images/videos: {@link Category} @@ -180,20 +203,12 @@ public class ItemsProvider { * in the relative order. */ @Nullable - public Cursor getAllCategories(@Nullable String[] mimeTypes, @Nullable UserId userId) { - if (DEBUG) { - Log.d(TAG, "getAllCategories() userId=" + userId - + " mimeTypes=" + Arrays.toString(mimeTypes)); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); - } - + public Cursor getAllCategories(@Nullable String[] mimeTypes, @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) { Trace.beginSection("ItemsProvider.getAllCategories"); try { - sNotificationHandler.onLoadingStarted(); - - return queryAlbums(URI_ALBUMS_ALL, mimeTypes, userId); + return queryAlbums(URI_ALBUMS_ALL, mimeTypes, userId, cancellationSignal); } finally { - sNotificationHandler.onLoadingFinished(); Trace.endSection(); } } @@ -211,32 +226,41 @@ public class ItemsProvider { * in the relative order. */ @Nullable - public Cursor getLocalCategories(@Nullable String[] mimeTypes, @Nullable UserId userId) { - if (DEBUG) { - Log.d(TAG, "getLocalCategories() userId=" + userId - + " mimeTypes=" + Arrays.toString(mimeTypes)); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); - } - + public Cursor getLocalCategories(@Nullable String[] mimeTypes, @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) { Trace.beginSection("ItemsProvider.getLocalCategories"); try { - return queryAlbums(URI_ALBUMS_LOCAL, mimeTypes, userId); + return queryAlbums(URI_ALBUMS_LOCAL, mimeTypes, userId, cancellationSignal); } finally { Trace.endSection(); } } @Nullable - private Cursor queryMedia(@NonNull Uri uri, int limit, String[] mimeTypes, - @NonNull Category category, @Nullable UserId userId) throws IllegalStateException { + private Cursor queryMedia(@NonNull Uri uri, PaginationParameters paginationParameters, + String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) { + return queryMedia(uri, paginationParameters, mimeTypes, category, userId, null, + cancellationSignal); + } + + @Nullable + private Cursor queryMedia(@NonNull Uri uri, PaginationParameters paginationParameters, + String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, + List<Integer> localIdSelection, + @Nullable CancellationSignal cancellationSignal) + throws IllegalStateException { if (userId == null) { userId = UserId.CURRENT_USER; } if (DEBUG) { - Log.d(TAG, "queryMedia() userId=" + userId + " uri=" + uri + " cat=" + category - + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); + Log.d(TAG, "queryMedia() uri=" + uri + + " cat=" + category + + " mimeTypes=" + Arrays.toString(mimeTypes) + + " limit=" + paginationParameters.getPageSize() + + " date_taken_before_ms = " + paginationParameters.getDateBeforeMs() + + " row_id = " + paginationParameters.getRowId()); } Trace.beginSection("ItemsProvider.queryMedia"); @@ -249,15 +273,25 @@ public class ItemsProvider { + MediaStore.AUTHORITY); return null; } - extras.putInt(QUERY_ARG_LIMIT, limit); + extras.putInt(QUERY_ARG_LIMIT, paginationParameters.getPageSize()); if (mimeTypes != null) { extras.putStringArray(MediaStore.QUERY_ARG_MIME_TYPE, mimeTypes); } extras.putString(MediaStore.QUERY_ARG_ALBUM_ID, category.getId()); extras.putString(MediaStore.QUERY_ARG_ALBUM_AUTHORITY, category.getAuthority()); + if (paginationParameters.getRowId() >= 0 + && paginationParameters.getDateBeforeMs() > Long.MIN_VALUE) { + extras.putInt(QUERY_ROW_ID, paginationParameters.getRowId()); + extras.putLong(QUERY_DATE_TAKEN_BEFORE_MS, paginationParameters.getDateBeforeMs()); + } + if (localIdSelection != null) { + extras.putIntegerArrayList(QUERY_LOCAL_ID_SELECTION, + (ArrayList<Integer>) localIdSelection); + } + result = client.query(uri, /* projection */ null, extras, - /* cancellationSignal */ null); + /* cancellationSignal */ cancellationSignal); return result; } catch (RemoteException | NameNotFoundException ignored) { // Do nothing, return null. @@ -281,15 +315,14 @@ public class ItemsProvider { @Nullable private Cursor queryAlbums(@NonNull Uri uri, @Nullable String[] mimeTypes, - @Nullable UserId userId) { + @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal) { if (userId == null) { userId = UserId.CURRENT_USER; } if (DEBUG) { - Log.d(TAG, "queryAlbums() userId=" + userId + " uri=" + uri + Log.d(TAG, "queryAlbums() uri=" + uri + " mimeTypes=" + Arrays.toString(mimeTypes)); - Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); } Trace.beginSection("ItemsProvider.queryAlbums"); @@ -307,7 +340,7 @@ public class ItemsProvider { } result = client.query(uri, /* projection */ null, extras, - /* cancellationSignal */ null); + /* cancellationSignal */ cancellationSignal); return result; } catch (RemoteException | NameNotFoundException ignored) { // Do nothing, return null. @@ -380,93 +413,64 @@ public class ItemsProvider { return !TextUtils.isEmpty(uri.getUserInfo()); } - // TODO(b/257887919): Build proper UI and remove all this monstrosity below! - private static volatile @Nullable NotificationHandler sNotificationHandler; - - private static void ensureNotificationHandler(@NonNull Context context) { - if (sNotificationHandler == null) { - synchronized (PickerSyncController.class) { - if (sNotificationHandler == null) { - sNotificationHandler = new NotificationHandler(context); + /** + * Fetches file Uris for items having {@link com.android.providers.media.MediaGrants} for the + * given package. Returns an empty list if no grants are present. + */ + @NonNull + public List<Uri> fetchReadGrantedItemsUrisForPackage(int packageUid, String[] mimeTypes) { + final ContentResolver resolver = mContext.getContentResolver(); + try (ContentProviderClient client = resolver.acquireContentProviderClient(AUTHORITY)) { + assert client != null; + final Bundle extras = new Bundle(); + extras.putInt(Intent.EXTRA_UID, packageUid); + extras.putStringArray(EXTRA_MIME_TYPE_SELECTION, mimeTypes); + List<Uri> filesUriList = new ArrayList<>(); + try (Cursor c = client.query(Uri.parse(MEDIA_GRANTS_URI_PATH), + /* projection= */ null, + /* queryArgs= */ extras, + null)) { + while (c.moveToNext()) { + final String file_path = c.getString(c.getColumnIndexOrThrow(DATA)); + final Integer file_id = c.getInt(c.getColumnIndexOrThrow(FILE_ID_COLUMN)); + filesUriList.add(getContentUriForPath( + file_path).buildUpon().appendPath(String.valueOf(file_id)).build()); } } + return filesUriList; + } catch (RemoteException e) { + throw e.rethrowAsRuntimeException(); } } - private static class NotificationHandler extends Handler { - static final int MESSAGE_CODE_STARTED_LOADING = 1; - static final int MESSAGE_CODE_TICK = 2; - static final int MESSAGE_CODE_FINISHED_LOADING = 3; - - static final int FIRST_TICK_DELAY = 1_000; // 1 second - static final int TICK_DELAY = 30_000; // 30 seconds - - final Context mContext; - - NotificationHandler(@NonNull Context context) { - // It will be running on the UI thread. - super(Looper.getMainLooper()); - mContext = context.getApplicationContext(); + /** + * Sends a data init notification to the MP process. + */ + public void initPhotoPickerData(@Nullable String albumId, + @Nullable String albumAuthority, + boolean initLocalOnlyData, + @Nullable UserId userId) { + if (userId == null) { + Log.e(TAG, "Could not determine the current active user id in Picker. " + + "Init media call cannot go through."); + return; } - @Override - public void handleMessage(@NonNull Message msg) { - switch (msg.what) { - case MESSAGE_CODE_STARTED_LOADING: - if (hasMessages(MESSAGE_CODE_TICK)) { - // Already have scheduled ticks - do nothing. - return; - } - // Wait 1 sec before actually showing the first notification (so that we don't - // annoy users with our Toasts if the loading actually takes less than 1 sec). - sendTickMessageDelayed(/* seqNum */ 1, FIRST_TICK_DELAY); - break; - - case MESSAGE_CODE_TICK: - final int seqNum = msg.arg1; - - // These Strings are intentionally hardcoded here instead of being added to - // the res/values/strings.xml. - // They are to be used in droidfood only, not to be translated, and must be - // removed very soon! - final String text; - if (seqNum == 1) { - text = "Syncing your cloud media library..."; - } else { - text = "Still syncing your cloud media library..."; - } - Toast.makeText(mContext, "[Dogfood: known issue] " + text, LENGTH_LONG).show(); - - // Do not show more than 10 of these. - if (seqNum < 10) { - // Show next tick in 30 seconds. - sendTickMessageDelayed(/* seqNum */ seqNum + 1, TICK_DELAY); - } - break; - - case MESSAGE_CODE_FINISHED_LOADING: - removeMessages(MESSAGE_CODE_STARTED_LOADING); - removeMessages(MESSAGE_CODE_TICK); - break; - - default: - super.handleMessage(msg); + try (ContentProviderClient client = getContentProviderClient(userId)) { + if (client == null) { + throw new IllegalStateException("ContentProviderClient is null."); } + sendInitPhotoPickerDataNotification(client, albumId, albumAuthority, initLocalOnlyData); + } catch (RuntimeException | NameNotFoundException | RemoteException e) { + Log.e(TAG, "Could not send init media call to Media Provider", e); } + } - void onLoadingStarted() { - sendEmptyMessage(MESSAGE_CODE_STARTED_LOADING); - } - - void onLoadingFinished() { - sendEmptyMessage(MESSAGE_CODE_FINISHED_LOADING); - } - - private void sendTickMessageDelayed(int seqNum, int delay) { - final Message message = obtainMessage(MESSAGE_CODE_TICK); - message.arg1 = seqNum; - - sendMessageDelayed(message, delay); - } + @Nullable + private ContentProviderClient getContentProviderClient(@NonNull UserId userId) + throws NameNotFoundException { + return userId + .getContentResolver(mContext) + .acquireContentProviderClient(AUTHORITY); } } diff --git a/src/com/android/providers/media/photopicker/data/PaginationParameters.java b/src/com/android/providers/media/photopicker/data/PaginationParameters.java new file mode 100644 index 000000000..2d05eb447 --- /dev/null +++ b/src/com/android/providers/media/photopicker/data/PaginationParameters.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2023 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.photopicker.data.PickerDbFacade.QueryFilterBuilder.INT_DEFAULT; + +/** + * Holder for parameters required for pagination of photos and category items grid recyclerView in + * photoPicker. + */ +public class PaginationParameters { + private int mPageSize = INT_DEFAULT; + private long mDateBeforeMs = Long.MIN_VALUE; + private int mRowId = INT_DEFAULT; + public static final int PAGINATION_PAGE_SIZE_ITEMS = 600; + + public static final int PAGINATION_PAGE_SIZE_ALBUM_ITEMS = 600; + + /** + * Instantiates UI pagination parameters for photoPicker. Use this when all the fields needs to + * be set to default, i.e. to return complete list of items. + */ + public PaginationParameters() { + } + + /** + * Instantiates UI pagination parameters for photoPicker. + * + * <p>The parameters will be used similar to this sample query : + * {@code SELECT * FROM TABLE_NAME WHERE (column_date_before_ms < dateBeforeMs + * OR ( column_date_before_ms = dateBeforeMs AND column_row_id < rowID)) LIMIT pageSize;} + * + * @param pageSize used to represent the upper limit of the number of rows that should be + * returned by the query. Set as -1 to ignore this parameter in the query. + * @param dateBeforeMs when set items with date less that this will be returned. Set as -1 to + * ignore this parameter in the query. + * @param rowId when set items with id less than this will be returned. Set as -1 to + * ignore this parameter in the query. + */ + public PaginationParameters(int pageSize, long dateBeforeMs, int rowId) { + mPageSize = pageSize; + mDateBeforeMs = dateBeforeMs; + mRowId = rowId; + } + + /** + * Instantiates UI pagination parameters for photoPicker. + * + * <p>When using this constructor the value for pageSize will be the default value i.e. -1.</p> + * + * @param dateBeforeMs when set items with date less that this will be returned. Set as -1 to + * ignore this parameter in the query. + * @param rowId when set items with id less than this will be returned. Set as -1 to + * ignore this parameter in the query. + */ + public PaginationParameters(long dateBeforeMs, int rowId) { + this(PAGINATION_PAGE_SIZE_ITEMS, dateBeforeMs, rowId); + } + + /** + * @return page size for pagination. It is used as the LIMIT clause in the query to database. + */ + public int getPageSize() { + return mPageSize; + } + + /** + * @return date in ms which can be used as the parameter in the query to load items. + * + * <p>This is combination with row id is used to find the next page of data.</p> + * + * <b>Note: This parameter is only used in the query if the row id is set. Else it is + * ignored.</b> + */ + public Long getDateBeforeMs() { + return mDateBeforeMs; + } + + /** + * @return row id which can be used as the parameter in the query to load items. + * + * <p>This is combination with date_taken_before_ms is used to find the next page of data.</p> + * + * <p>When the {@link PaginationParameters#mDateBeforeMs} for two rows is same, this + * parameter is used to figure out which row to return.</p> + */ + public int getRowId() { + return mRowId; + } +} diff --git a/src/com/android/providers/media/photopicker/data/PickerDatabaseHelper.java b/src/com/android/providers/media/photopicker/data/PickerDatabaseHelper.java index 6e8df9b2b..141807c46 100644 --- a/src/com/android/providers/media/photopicker/data/PickerDatabaseHelper.java +++ b/src/com/android/providers/media/photopicker/data/PickerDatabaseHelper.java @@ -40,9 +40,8 @@ public class PickerDatabaseHelper extends SQLiteOpenHelper { private static final String TAG = "PickerDatabaseHelper"; public static final String PICKER_DATABASE_NAME = "picker.db"; - - private static final int VERSION_T = 9; - public static final int VERSION_LATEST = VERSION_T; + private static final int VERSION_U = 11; + public static final int VERSION_LATEST = VERSION_U; final Context mContext; final String mName; @@ -98,12 +97,15 @@ public class PickerDatabaseHelper extends SQLiteOpenHelper { private void resetData(SQLiteDatabase db) { clearPickerPrefs(mContext); + + dropAllTables(db); + createLatestSchema(db); createLatestIndexes(db); } @VisibleForTesting - static void makePristineSchema(SQLiteDatabase db) { + static void dropAllTables(SQLiteDatabase db) { // drop all tables Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'table'", null, null, null, null); @@ -114,26 +116,13 @@ public class PickerDatabaseHelper extends SQLiteOpenHelper { c.close(); } - @VisibleForTesting - static void makePristineIndexes(SQLiteDatabase db) { - // drop all indexes - Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'index'", - null, null, null, null); - while (c.moveToNext()) { - if (c.getString(0).startsWith("sqlite_")) continue; - db.execSQL("DROP INDEX IF EXISTS " + c.getString(0)); - } - c.close(); - } - private static void createLatestSchema(SQLiteDatabase db) { - makePristineSchema(db); db.execSQL("CREATE TABLE media (_id INTEGER PRIMARY KEY AUTOINCREMENT," + "local_id TEXT," + "cloud_id TEXT UNIQUE," + "is_visible INTEGER CHECK(is_visible == 1)," - + "date_taken_ms INTEGER NOT NULL CHECK(date_taken_ms >= 0)," + + "date_taken_ms INTEGER NOT NULL," + "sync_generation INTEGER NOT NULL CHECK(sync_generation >= 0)," + "width INTEGER," + "height INTEGER," @@ -150,7 +139,7 @@ public class PickerDatabaseHelper extends SQLiteOpenHelper { + "local_id TEXT," + "cloud_id TEXT," + "album_id TEXT," - + "date_taken_ms INTEGER NOT NULL CHECK(date_taken_ms >= 0)," + + "date_taken_ms INTEGER NOT NULL," + "sync_generation INTEGER NOT NULL CHECK(sync_generation >= 0)," + "size_bytes INTEGER NOT NULL CHECK(size_bytes > 0)," + "duration_ms INTEGER CHECK(duration_ms >= 0)," @@ -163,21 +152,20 @@ public class PickerDatabaseHelper extends SQLiteOpenHelper { } private static void createLatestIndexes(SQLiteDatabase db) { - makePristineIndexes(db); db.execSQL("CREATE INDEX local_id_index on media(local_id)"); db.execSQL("CREATE INDEX cloud_id_index on media(cloud_id)"); db.execSQL("CREATE INDEX is_visible_index on media(is_visible)"); - db.execSQL("CREATE INDEX date_taken_index on media(date_taken_ms)"); db.execSQL("CREATE INDEX size_index on media(size_bytes)"); db.execSQL("CREATE INDEX mime_type_index on media(mime_type)"); db.execSQL("CREATE INDEX is_favorite_index on media(is_favorite)"); + db.execSQL("CREATE INDEX date_taken_row_id_index on media(date_taken_ms, _id)"); db.execSQL("CREATE INDEX local_id_album_index on album_media(local_id)"); db.execSQL("CREATE INDEX cloud_id_album_index on album_media(cloud_id)"); - db.execSQL("CREATE INDEX date_taken_album_index on album_media(date_taken_ms)"); db.execSQL("CREATE INDEX size_album_index on album_media(size_bytes)"); db.execSQL("CREATE INDEX mime_type_album_index on album_media(mime_type)"); + db.execSQL("CREATE INDEX date_taken_album_row_id_index on album_media(date_taken_ms,_id)"); } private static void clearPickerPrefs(Context context) { diff --git a/src/com/android/providers/media/photopicker/data/PickerDbFacade.java b/src/com/android/providers/media/photopicker/data/PickerDbFacade.java index 15cf1505c..3fcdad982 100644 --- a/src/com/android/providers/media/photopicker/data/PickerDbFacade.java +++ b/src/com/android/providers/media/photopicker/data/PickerDbFacade.java @@ -22,6 +22,7 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_ import static android.provider.CloudMediaProviderContract.MediaColumns; import static android.provider.MediaStore.PickerMediaColumns; +import static com.android.providers.media.photopicker.PickerSyncController.PAGE_SIZE; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString; import static com.android.providers.media.util.DatabaseUtils.replaceMatchAnyChar; @@ -32,6 +33,7 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; +import android.database.MergeCursor; import android.database.sqlite.SQLiteConstraintException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; @@ -47,10 +49,18 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.providers.media.photopicker.PickerSyncController; - +import com.android.providers.media.photopicker.data.model.Item; +import com.android.providers.media.photopicker.sync.CloseableReentrantLock; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.sync.SyncTrackerRegistry; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; +import com.android.providers.media.util.MimeUtils; + +import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; /** * This is a facade that hides the complexities of executing some SQL statements on the picker db. @@ -59,34 +69,32 @@ import java.util.Objects; */ public class PickerDbFacade { private static final String VIDEO_MIME_TYPES = "video/%"; - - // TODO(b/278562157): If there is a dependency on - // {@link PickerSyncController#mCloudProviderLock}, always acquire - // {@link PickerSyncController#mCloudProviderLock} before {@link mLock} to avoid deadlock. - @NonNull - private final Object mLock = new Object(); private final Context mContext; private final SQLiteDatabase mDatabase; + private final PickerSyncLockManager mPickerSyncLockManager; private final String mLocalProvider; // This is the cloud provider the database is synced with. It can be set as null to disable // cloud queries when database is not in sync with the current cloud provider. @Nullable private String mCloudProvider; - public PickerDbFacade(Context context) { - this(context, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); + public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager) { + this(context, pickerSyncLockManager, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); } @VisibleForTesting - public PickerDbFacade(Context context, String localProvider) { - this(context, localProvider, new PickerDatabaseHelper(context)); + public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, + String localProvider) { + this(context, pickerSyncLockManager, localProvider, new PickerDatabaseHelper(context)); } @VisibleForTesting - public PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper) { + public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, + String localProvider, PickerDatabaseHelper dbHelper) { mContext = context; mLocalProvider = localProvider; mDatabase = dbHelper.getWritableDatabase(); + mPickerSyncLockManager = pickerSyncLockManager; } private static final String TAG = "PickerDbFacade"; @@ -148,6 +156,7 @@ public class PickerDbFacade { String.format("%s < ? OR (%s = ? AND %s < ?)", KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID); private static final String WHERE_ALBUM_ID = KEY_ALBUM_ID + " = ?"; + private static final String WHERE_LOCAL_ID_IN = KEY_LOCAL_ID + " IN "; // This where clause returns all rows for media items that are local-only and are marked as // favorite. @@ -220,19 +229,39 @@ public class PickerDbFacade { /** * Sets the cloud provider to be returned after querying the picker db * If null, cloud media will be excluded from all queries. + * This should not be used in picker sync paths because we should not wait on a lock + * indefinitely during the picker sync process. + * Use {@link this#setCloudProviderWithTimeout} instead. */ public void setCloudProvider(String authority) { - synchronized (mLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { mCloudProvider = authority; } } /** - * Returns the cloud provider that will be returned after querying the picker db + * Sets the cloud provider to be returned after querying the picker db + * If null, cloud media will be excluded from all queries. + * This should be used in picker sync paths because we should not wait on a lock + * indefinitely during the picker sync process + */ + public void setCloudProviderWithTimeout(String authority) throws UnableToAcquireLockException { + try (CloseableReentrantLock ignored = + mPickerSyncLockManager.tryLock(PickerSyncLockManager.DB_CLOUD_LOCK)) { + mCloudProvider = authority; + } + } + + /** + * Returns the cloud provider that will be returned after querying the picker db. + * This should not be used in picker sync paths because we should not wait on a lock + * indefinitely during the picker sync process. */ @VisibleForTesting public String getCloudProvider() { - synchronized (mLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { return mCloudProvider; } } @@ -393,6 +422,16 @@ public class PickerDbFacade { return null; } + + /** + * Returns the first date taken present in the columns affected by the DB write operation + * when this method is overridden. Otherwise, it returns Long.MIN_VALUE. + */ + public long getFirstDateTakenMillis() { + Log.e(TAG, "Method getFirstDateTakenMillis() is not overridden. " + + "It will always return Long.MIN_VALUE"); + return Long.MIN_VALUE; + } } /** @@ -442,32 +481,41 @@ public class PickerDbFacade { final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; int counter = 0; - while (cursor.moveToNext()) { - ContentValues values = cursorToContentValue(cursor, isLocal); + if (cursor.getCount() > PAGE_SIZE) { + Log.w(TAG, + String.format("Expected a cursor page size of %d, but received a cursor " + + "with %d rows instead.", PAGE_SIZE, cursor.getCount())); + } - String[] upsertArgs = {values.getAsString(isLocal ? - KEY_LOCAL_ID : KEY_CLOUD_ID)}; - if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { - counter++; - continue; - } + if (cursor.moveToFirst()) { + do { + ContentValues values = cursorToContentValue(cursor, isLocal); - // Because we want to prioritize visible local media over visible cloud media, - // we do the following if the upsert above failed - if (isLocal) { - // For local syncs, we attempt hiding the visible cloud media - String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID)); - demoteCloudMediaToHidden(cloudId); - } else { - // For cloud syncs, we prepare an upsert as hidden cloud media - values.putNull(KEY_IS_VISIBLE); - } + String[] upsertArgs = {values.getAsString(isLocal ? KEY_LOCAL_ID + : KEY_CLOUD_ID)}; + if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { + counter++; + continue; + } - // Now attempt upsert again, this should succeed - if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { - counter++; - } + // Because we want to prioritize visible local media over visible cloud media, + // we do the following if the upsert above failed + if (isLocal) { + // For local syncs, we attempt hiding the visible cloud media + String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID)); + demoteCloudMediaToHidden(cloudId); + } else { + // For cloud syncs, we prepare an upsert as hidden cloud media + values.putNull(KEY_IS_VISIBLE); + } + + // Now attempt upsert again, this should succeed + if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { + counter++; + } + } while (cursor.moveToNext()); } + return counter; } @@ -517,6 +565,8 @@ public class PickerDbFacade { } private static final class RemoveMediaOperation extends DbWriteOperation { + private static final String[] sDateTakenProjection = new String[] {KEY_DATE_TAKEN_MS}; + private long mFirstDateTakenMillis = Long.MIN_VALUE; private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) { super(database, isLocal); @@ -530,6 +580,10 @@ public class PickerDbFacade { int counter = 0; while (cursor.moveToNext()) { + if (cursor.isFirst()) { + updateFirstDateTakenMillis(cursor, isLocal); + } + // Need to fetch the local_id before delete because for cloud items // we need a db query to fetch the local_id matching the id received from // cursor (cloud_id). @@ -549,6 +603,11 @@ public class PickerDbFacade { return counter; } + @Override + public long getFirstDateTakenMillis() { + return mFirstDateTakenMillis; + } + private void promoteCloudMediaToVisible(@Nullable String localId) { if (localId == null) { return; @@ -585,6 +644,34 @@ public class PickerDbFacade { /* columnIndex */ 0); } } + + private void updateFirstDateTakenMillis(Cursor inputCursor, boolean isLocal) { + final int idIndex = inputCursor + .getColumnIndex(CloudMediaProviderContract.MediaColumns.ID); + if (idIndex < 0) { + Log.e(TAG, "Id is not present in the cursor"); + return; + } + + final String id = inputCursor.getString(idIndex); + if (TextUtils.isEmpty((id))) { + Log.e(TAG, "Input id is empty"); + return; + } + + final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; + final String[] queryArgs = new String[]{id}; + + try (Cursor outputCursor = qb.query(getDatabase(), sDateTakenProjection, + /* selection */ null, queryArgs, /* groupBy */ null, /* having */ null, + /* orderBy */ null)) { + if (outputCursor.moveToFirst()) { + mFirstDateTakenMillis = outputCursor.getLong(/* columnIndex */ 0); + } else { + Log.e(TAG, "Could not get first date taken millis for media id: " + id); + } + } + } } private static final class ResetMediaOperation extends DbWriteOperation { @@ -631,10 +718,15 @@ public class PickerDbFacade { private final boolean mIsFavorite; private final boolean mIsVideo; public boolean mIsLocalOnly; + private int mPageSize; + private String mPageToken; + + private List<Integer> mLocalIdSelection; private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite, - boolean isVideo, boolean isLocalOnly) { + boolean isVideo, boolean isLocalOnly, List<Integer> localIdSelection, int pageSize, + String pageToken) { this.mLimit = limit; this.mDateTakenBeforeMs = dateTakenBeforeMs; this.mDateTakenAfterMs = dateTakenAfterMs; @@ -645,21 +737,26 @@ public class PickerDbFacade { this.mIsFavorite = isFavorite; this.mIsVideo = isVideo; this.mIsLocalOnly = isLocalOnly; + this.mLocalIdSelection = localIdSelection; + this.mPageSize = pageSize; + this.mPageToken = pageToken; } } /** Builder for {@link Query} filter. */ public static class QueryFilterBuilder { + public static final int INT_DEFAULT = -1; public static final long LONG_DEFAULT = -1; public static final String STRING_DEFAULT = null; public static final String[] STRING_ARRAY_DEFAULT = null; public static final boolean BOOLEAN_DEFAULT = false; + public static final List LIST_DEFAULT = null; public static final int LIMIT_DEFAULT = 1000; private final int limit; - private long dateTakenBeforeMs = LONG_DEFAULT; - private long dateTakenAfterMs = LONG_DEFAULT; + private long mDateTakenBeforeMs = Long.MIN_VALUE; + private long mDateTakenAfterMs = Long.MIN_VALUE; private long id = LONG_DEFAULT; private String albumId = STRING_DEFAULT; private long sizeBytes = LONG_DEFAULT; @@ -667,18 +764,22 @@ public class PickerDbFacade { private boolean isFavorite = BOOLEAN_DEFAULT; private boolean mIsVideo = BOOLEAN_DEFAULT; private boolean mIsLocalOnly = BOOLEAN_DEFAULT; + private int mPageSize = INT_DEFAULT; + private String mPageToken = STRING_DEFAULT; + + private List<Integer> mLocalIdSelection = LIST_DEFAULT; public QueryFilterBuilder(int limit) { this.limit = limit; } public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) { - this.dateTakenBeforeMs = dateTakenBeforeMs; + this.mDateTakenBeforeMs = dateTakenBeforeMs; return this; } public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) { - this.dateTakenAfterMs = dateTakenAfterMs; + this.mDateTakenAfterMs = dateTakenAfterMs; return this; } @@ -698,6 +799,7 @@ public class PickerDbFacade { this.id = id; return this; } + public QueryFilterBuilder setAlbumId(String albumId) { this.albumId = albumId; return this; @@ -714,6 +816,14 @@ public class PickerDbFacade { } /** + * Sets the local id selection filter. + */ + public QueryFilterBuilder setLocalIdSelection(List<Integer> localIdSelection) { + this.mLocalIdSelection = localIdSelection; + return this; + } + + /** * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only * favorited items, however, if it is {@code false}, it returns all items including * favorited and non-favorited items. @@ -742,9 +852,26 @@ public class PickerDbFacade { return this; } + /** + * Sets the page size. + */ + public QueryFilterBuilder setPageSize(int pageSize) { + mPageSize = pageSize; + return this; + } + + /** + * Sets the page token. + */ + public QueryFilterBuilder setPageToken(String pageToken) { + mPageToken = pageToken; + return this; + } + public QueryFilter build() { - return new QueryFilter(limit, dateTakenBeforeMs, dateTakenAfterMs, id, albumId, - sizeBytes, mimeTypes, isFavorite, mIsVideo, mIsLocalOnly); + return new QueryFilter(limit, mDateTakenBeforeMs, mDateTakenAfterMs, id, albumId, + sizeBytes, mimeTypes, isFavorite, mIsVideo, mIsLocalOnly, mLocalIdSelection, + mPageSize, mPageToken); } } @@ -758,18 +885,55 @@ public class PickerDbFacade { * {@code limit}. They can also be filtered with {@code query}. */ public Cursor queryMediaForUi(QueryFilter query) { + if (query.mIsLocalOnly && query.mLocalIdSelection != null + && !query.mLocalIdSelection.isEmpty()) { + return queryMediaForUiWithLocalIdSelection(query); + } + final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); final String[] selectionArgs = buildSelectionArgs(qb, query); + if (query.mIsLocalOnly) { + return queryMediaForUi(qb, selectionArgs, query.mLimit, /* isLocalOnly*/true, + TABLE_MEDIA, /* cloudProvider*/ null); + } + + // If the cloud sync is in progress or the cloud provider has changed but a sync has not + // been completed and committed, {@link PickerDBFacade.mCloudProvider} will be + // {@code null}. + final String cloudProvider = getCloudProvider(); - final String cloudProvider; - synchronized (mLock) { - // If the cloud sync is in progress or the cloud provider has changed but a sync has not - // been completed and committed, {@link PickerDBFacade.mCloudProvider} will be - // {@code null}. - cloudProvider = mCloudProvider; + return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly, + TABLE_MEDIA, cloudProvider); + } + + + private Cursor queryMediaForUiWithLocalIdSelection(QueryFilter query) { + // Since 'WHERE IN' clause has an upper limit of items that can be included in the sql + // statement and also there is an upper limit to the size of the sql statement. + // Splitting the query into multiple smaller ones. + // This query will now process 150 items in a batch. + List<List<Integer>> listOfSelectionArgsForLocalId = splitArrayList( + query.mLocalIdSelection, + /* number of ids per query */ 150); + List<Cursor> resultCursor = new ArrayList<>(); + + for (List<Integer> selectionArgForLocalIdSelection : listOfSelectionArgsForLocalId) { + final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); + query.mLocalIdSelection = selectionArgForLocalIdSelection; + final String[] selectionArgs = buildSelectionArgs(qb, query); + resultCursor.add(queryMediaForUi(qb, selectionArgs, query.mLimit, true, + TABLE_MEDIA, /* cloud provider */null)); } - return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_MEDIA, cloudProvider); + return new MergeCursor(resultCursor.toArray(new Cursor[resultCursor.size()])); + } + + private static <T> List<List<T>> splitArrayList(List<T> list, int chunkSize) { + List<List<T>> subLists = new ArrayList<>(); + for (int i = 0; i < list.size(); i += chunkSize) { + subLists.add(list.subList(i, Math.min(i + chunkSize, list.size()))); + } + return subLists; } /** @@ -783,11 +947,12 @@ public class PickerDbFacade { * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of * {@code limit}. They can also be filtered with {@code query}. */ - public Cursor queryAlbumMediaForUi(QueryFilter query, String authority) { + public Cursor queryAlbumMediaForUi(@NonNull QueryFilter query, @NonNull String authority) { final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority)); final String[] selectionArgs = buildSelectionArgs(qb, query); - return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_ALBUM_MEDIA, authority); + return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly, + TABLE_ALBUM_MEDIA, authority); } /** @@ -808,19 +973,20 @@ public class PickerDbFacade { } if (authority.equals(mLocalProvider)) { - return queryMediaIdForAppsInternal(qb, projection, selectionArgs); + return queryMediaIdForAppsLocked(qb, projection, selectionArgs); } - synchronized (mLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { if (authority.equals(mCloudProvider)) { - return queryMediaIdForAppsInternal(qb, projection, selectionArgs); + return queryMediaIdForAppsLocked(qb, projection, selectionArgs); } } return null; } - private Cursor queryMediaIdForAppsInternal(@NonNull SQLiteQueryBuilder qb, + private Cursor queryMediaIdForAppsLocked(@NonNull SQLiteQueryBuilder qb, @NonNull String[] projection, @NonNull String[] selectionArgs) { return qb.query(mDatabase, getMediaStoreProjectionLocked(projection), /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null, @@ -831,7 +997,7 @@ public class PickerDbFacade { * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code * query} */ - public Cursor getMergedAlbums(QueryFilter query) { + public Cursor getMergedAlbums(QueryFilter query, String cloudProvider) { final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION); List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS); for (String albumId : mergedAlbums) { @@ -859,7 +1025,9 @@ public class PickerDbFacade { } long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT); - if (count == 0) { + + // We want to display empty merged folder in case of cloud picker. + if (shouldHideMergedAlbum(query, albumId, cloudProvider, count)) { continue; } @@ -876,6 +1044,27 @@ public class PickerDbFacade { return c; } + private static boolean shouldHideMergedAlbum(QueryFilter query, String albumId, + String cloudProvider, long count) { + final boolean isAlbumEmpty = (count == 0); + final boolean shouldNotShowCloudItems = (query.mIsLocalOnly || cloudProvider == null); + + return (isAlbumEmpty && (shouldNotShowCloudItems || hideVideosAlbum(query, albumId))); + } + + private static boolean hideVideosAlbum(QueryFilter query, String albumId) { + String[] mimeTypes = query.mMimeTypes; + if (!albumId.equals(ALBUM_ID_VIDEOS) || mimeTypes == null) { + return false; + } + for (String mimeType : mimeTypes) { + if (MimeUtils.isVideoMimeType(mimeType)) { + return false; + } + } + return true; + } + private String[] getMergedAlbumProjection() { return new String[] { "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT, @@ -897,27 +1086,40 @@ public class PickerDbFacade { return mLocalProvider.equals(authority); } + /** + * Returns sorted and deduped cloud and local media or album content items from the picker db. + */ private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, - int limit, String tableName, String authority) { + int limit, boolean isLocalOnly, String tableName, String authority) { // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id' final String orderBy = getOrderClause(tableName); final String limitStr = String.valueOf(limit); + if (isLocalOnly) { + qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); + return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr); + } + // Hold lock while checking the cloud provider and querying so that cursor extras containing // the cloud provider is consistent with the cursor results and doesn't race with // #setCloudProvider - synchronized (mLock) { + try (CloseableReentrantLock ignored = mPickerSyncLockManager + .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) { // TODO(b/278086344): If cloud provider is null or has changed from what we received // from the UI, skip all cloud items in the picker db. qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); } - - return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null, - selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr); + return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr); } } + private Cursor queryMediaForUiLocked(SQLiteQueryBuilder qb, String[] selectionArgs, + String orderBy, String limitStr) { + return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null, + selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr); + } + private static String getOrderClause(String tableName) { return "date_taken_ms DESC," + tableName + "._id DESC"; } @@ -927,6 +1129,8 @@ public class PickerDbFacade { getProjectionAuthorityLocked(), getProjectionDataLocked(MediaColumns.DATA), getProjectionId(MediaColumns.ID), + // The id in the picker.db table represents the row id. This is used in UI pagination. + getProjectionSimple(KEY_ID, Item.ROW_ID), getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS), getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION), getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES), @@ -1176,6 +1380,22 @@ public class PickerDbFacade { selectArgs.add(query.mAlbumId); } + if (query.mLocalIdSelection != null && !query.mLocalIdSelection.isEmpty()) { + StringBuilder localIdSelectionPlaceholder = new StringBuilder("("); + for (int itr = 0; itr < query.mLocalIdSelection.size(); itr++) { + localIdSelectionPlaceholder.append("?,"); + } + localIdSelectionPlaceholder.deleteCharAt(localIdSelectionPlaceholder.length() - 1); + localIdSelectionPlaceholder.append(")"); + + // Append the where clause for local id selection to the query builder. + qb.appendWhereStandalone(WHERE_LOCAL_ID_IN + localIdSelectionPlaceholder); + + // Add local ids to the selection args. + selectArgs.addAll(query.mLocalIdSelection.stream().map( + String::valueOf).collect(Collectors.toList())); + } + if (selectArgs.isEmpty()) { return null; } @@ -1349,50 +1569,94 @@ public class PickerDbFacade { final boolean isLocal = isLocal(); final String albumId = getAlbumId(); final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); + final SQLiteQueryBuilder qbMedia = createMediaQueryBuilder(); int counter = 0; - while (cursor.moveToNext()) { - ContentValues values = cursorToContentValue(cursor, isLocal, albumId); - - // In case of cloud albums, cloud provider returns both local and cloud ids. - // We give preference to inserting media data for the local copy of an item instead - // of the cloud copy. Hence, if local copy is available, fetch metadata from media - // table and update the album_media row accordingly. - if (!isLocal) { - final String localId = values.getAsString(KEY_LOCAL_ID); - final String cloudId = values.getAsString(KEY_CLOUD_ID); - if (!TextUtils.isEmpty(localId) && !TextUtils.isEmpty(cloudId)) { - // Fetch local media item details from media table. - try (Cursor cursorLocalMedia = getLocalMediaMetadata(localId)) { - if (cursorLocalMedia != null && cursorLocalMedia.getCount() == 1) { - // If local media item details are present in the media table, - // update content values and remove cloud id. - values.putNull(KEY_CLOUD_ID); - updateContentValues(values, cursorLocalMedia); - } else { - // If local media item details are NOT present in the media table, - // insert cloud row after removing local_id. This will only happen - // when local id points to a deleted item. - values.putNull(KEY_LOCAL_ID); + if (cursor.getCount() > PAGE_SIZE) { + Log.w(TAG, + String.format("Expected a cursor page size of %d, but received a cursor " + + "with %d rows instead.", PAGE_SIZE, cursor.getCount())); + } + + if (cursor.moveToFirst()) { + do { + ContentValues values = cursorToContentValue(cursor, isLocal, albumId); + + // In case of cloud albums, cloud provider returns both local and cloud ids. + // We give preference to inserting media data for the local copy of an item + // instea of the cloud copy. Hence, if local copy is available, fetch metadata + // from media table and update the album_media row accordingly. + if (!isLocal) { + final String localId = values.getAsString(KEY_LOCAL_ID); + final String cloudId = values.getAsString(KEY_CLOUD_ID); + if (!TextUtils.isEmpty(localId) && !TextUtils.isEmpty(cloudId)) { + // Fetch local media item details from media table. + try (Cursor cursorLocalMedia = getLocalMediaMetadata(localId)) { + if (cursorLocalMedia != null && cursorLocalMedia.getCount() == 1) { + // If local media item details are present in the media table, + // update content values and remove cloud id. + values.putNull(KEY_CLOUD_ID); + updateContentValues(values, cursorLocalMedia); + } else { + // If local media item details are NOT present in the media + // table, insert cloud row after removing local_id. This will + // only happen when local id points to a deleted item. + values.putNull(KEY_LOCAL_ID); + } } } } - } - try { - if (qb.insert(getDatabase(), values) > 0) { - counter++; - } else { - Log.v(TAG, "Failed to insert album_media. ContentValues: " + values); + try { + if (qb.insert(getDatabase(), values) > 0) { + counter++; + } else { + Log.v(TAG, "Failed to insert album_media. ContentValues: " + values); + } + } catch (SQLiteConstraintException e) { + Log.v(TAG, "Failed to insert album_media. ContentValues: " + values, e); } - } catch (SQLiteConstraintException e) { - Log.v(TAG, "Failed to insert album_media. ContentValues: " + values, e); - } + + // Check if a Cloud sync is running, and additionally insert this row to media + // table if true. + maybeInsertFileToMedia(qbMedia, cursor, isLocal); + } while (cursor.moveToNext()); } return counter; } + /** + * Will (possibly) insert this file to the Picker database's media table if there's an + * existing Cloud Sync running. + * + * <p>This is necessary to guarantee it exists in case it is selected by the user. (So that + * the pre-loader can load it to the device before the session is closed.) + * + * @param queryBuilder The media table query builder to use for the insert + * @param cursor The current cursor being processed (this method does not advance the + * cursor). + * @param isLocal Whether this is the local provider sync or not. + */ + private void maybeInsertFileToMedia( + SQLiteQueryBuilder queryBuilder, Cursor cursor, boolean isLocal) { + if (SyncTrackerRegistry.getCloudSyncTracker().pendingSyncFutures().size() > 0) { + ContentValues values = cursorToContentValue(cursor, isLocal); + Log.d( + TAG, + String.format( + "Encountered running Cloud sync during AddAlbumMediaOperation while" + + " processing row. Will additional insert to media table: %s", + values)); + try { + queryBuilder.insert(getDatabase(), values); + } catch (SQLiteConstraintException ignored) { + // If we hit a constraint exception it means this row is already in media, + // so nothing to do here. + } + } + } + private void updateContentValues(ContentValues values, Cursor cursor) { if (cursor.moveToFirst()) { for (int columnIndex = 0; columnIndex < cursor.getColumnCount(); columnIndex++) { @@ -1426,4 +1690,13 @@ public class PickerDbFacade { /* orderBy */ null); } } + + /** + * Print the {@link PickerDbFacade} state into the given stream. + */ + public void dump(PrintWriter writer) { + writer.println("Picker db facade state:"); + writer.println(" mLocalProvider=" + getLocalProvider()); + writer.println(" mCloudProvider=" + getCloudProvider()); + } } diff --git a/src/com/android/providers/media/photopicker/data/PickerSyncRequestExtras.java b/src/com/android/providers/media/photopicker/data/PickerSyncRequestExtras.java new file mode 100644 index 000000000..f479d510e --- /dev/null +++ b/src/com/android/providers/media/photopicker/data/PickerSyncRequestExtras.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2023 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.photopicker.data.CloudProviderQueryExtras.isMergedAlbum; + +import android.os.Bundle; +import android.provider.MediaStore; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +/** + * Encapsulate all picker sync request arguments related logic. + */ +public class PickerSyncRequestExtras { + @Nullable + private final String mAlbumId; + @Nullable + private final String mAlbumAuthority; + private final boolean mInitLocalOnlyData; + public PickerSyncRequestExtras(@Nullable String albumId, + @Nullable String albumAuthority, + boolean initLocalOnlyData) { + mAlbumId = albumId; + mAlbumAuthority = albumAuthority; + mInitLocalOnlyData = initLocalOnlyData; + } + + /** + * Create a {@link PickerSyncRequestExtras} object from an input bundle. + */ + public static PickerSyncRequestExtras fromBundle(@NonNull Bundle extras) { + Objects.requireNonNull(extras); + + final String albumId = extras.getString(MediaStore.EXTRA_ALBUM_ID); + final String albumAuthority = extras.getString(MediaStore.EXTRA_ALBUM_AUTHORITY); + final boolean initLocalOnlyData = + extras.getBoolean(MediaStore.EXTRA_LOCAL_ONLY); + return new PickerSyncRequestExtras(albumId, albumAuthority, initLocalOnlyData); + } + + /** + * Returns true when media data should be synced. + */ + public boolean shouldSyncMediaData() { + return TextUtils.isEmpty(mAlbumId); + } + + /** + * Returns true when only local data needs to be synced. + */ + public boolean shouldSyncLocalOnlyData() { + return mInitLocalOnlyData; + } + + /** + * Returns true when the sync request is for a merged album. + */ + public boolean shouldSyncMergedAlbum() { + return isMergedAlbum(mAlbumId); + } + + /** + * Return album id for the sync request. + */ + @Nullable + public String getAlbumId() { + return mAlbumId; + } + + /** + * Return album authority for the sync request. + */ + @Nullable + public String getAlbumAuthority() { + return mAlbumAuthority; + } +} diff --git a/src/com/android/providers/media/photopicker/data/Selection.java b/src/com/android/providers/media/photopicker/data/Selection.java index 4894977ea..d7667d334 100644 --- a/src/com/android/providers/media/photopicker/data/Selection.java +++ b/src/com/android/providers/media/photopicker/data/Selection.java @@ -16,10 +16,12 @@ package com.android.providers.media.photopicker.data; +import android.annotation.Nullable; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.provider.MediaStore; +import android.util.Log; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -27,31 +29,126 @@ import androidx.lifecycle.MutableLiveData; import com.android.providers.media.photopicker.data.model.Item; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * A class that tracks Selection */ public class Selection { + /** + * Contains positions of checked Item at UI. {@link #mCheckedItemIndexes} may have more number + * of indexes , from the number of items present in {@link #mSelectedItems}. The index in + * {@link #mCheckedItemIndexes} is a potential index that needs to be rechecked in + * notifyItemChanged() at the time of deselecting the unavailable item at UI when user is + * offline and tries adding unavailable non cached items. the item corresponding to the index in + * {@link #mCheckedItemIndexes} may no longer be selected. + */ + private final Map<Item, Integer> mCheckedItemIndexes = new HashMap<>(); + // The list of selected items. - private Map<Uri, Item> mSelectedItems = new HashMap<>(); + private Map<Uri, Item> mSelectedItems = new LinkedHashMap<>(); + private Map<Uri, MutableLiveData<Integer>> mSelectedItemsOrder = new HashMap<>(); + private Map<String, Item> mItemGrantRevocationMap = new HashMap<>(); + private MutableLiveData<Integer> mSelectedItemSize = new MutableLiveData<>(); // The list of selected items for preview. This needs to be saved separately so that if activity // gets killed, we will still have deselected items for preview. private List<Item> mSelectedItemsForPreview = new ArrayList<>(); + private boolean mIsSelectionOrdered = false; private boolean mSelectMultiple = false; private int mMaxSelectionLimit = 1; // This is set to false when max selection limit is reached. private boolean mIsSelectionAllowed = true; + private int mTotalNumberOfPreGrantedItems = 0; + + private Set<String> mPreGrantedItemsSet; + + private static final String TAG = "PhotoPickerSelection"; + + /** + * Updates the list of pre granted items and the count of selected items. + */ + public void setPreGrantedItemSet(@Nullable Set<String> preGrantedItemSet) { + if (preGrantedItemSet != null) { + mPreGrantedItemsSet = preGrantedItemSet; + setTotalNumberOfPreGrantedItems(preGrantedItemSet.size()); + Log.d(TAG, "Pre-Granted items have been loaded. Number of items:" + + preGrantedItemSet.size()); + } else { + mPreGrantedItemsSet = new HashSet<>(0); + Log.d(TAG, "No Pre-Granted items present"); + } + } + + /** + * @return a set of item ids that are pre granted for the current package and user. + */ + @Nullable + public Set<String> getPreGrantedItems() { + return mPreGrantedItemsSet; + } + /** * @return {@link #mSelectedItems} - A {@link List} of selected {@link Item} */ public List<Item> getSelectedItems() { - return Collections.unmodifiableList(new ArrayList<>(mSelectedItems.values())); + ArrayList<Item> result = new ArrayList<>(mSelectedItems.values()); + return Collections.unmodifiableList(result); + } + + /** + * @return A {@link Set} of selected {@link Item} ids. + */ + public Set<String> getSelectedItemsIds() { + return mSelectedItems.values().stream().map(Item::getId).collect( + Collectors.toSet()); + } + + /** + * @return A {@link List} of selected {@link Item} that do not hold a READ_GRANT. + */ + public List<Item> getSelectedItemsWithoutGrants() { + return mSelectedItems.values().stream().filter((Item item) -> !item.isPreGranted()) + .collect(Collectors.toList()); + } + + /** + * @return Indexes - A {@link List} of checked {@link Item} positions. + */ + public Collection<Integer> getCheckedItemsIndexes() { + return mCheckedItemIndexes.values(); + } + + /** + * @return A {@link List} of items for which the grants need to be revoked. + */ + public List<Item> getPreGrantedItemsToBeRevoked() { + return mItemGrantRevocationMap.values().stream().collect(Collectors.toList()); + } + + /** + * @return A {@link List} of ids for which the grants need to be revoked. + */ + public List<String> getPreGrantedItemIdsToBeRevoked() { + return mItemGrantRevocationMap.keySet().stream().collect(Collectors.toList()); + } + + /** + * Sets the count of pre granted items to ensure that the correct number is displayed in + * preview and on the add button. + */ + public void setTotalNumberOfPreGrantedItems(int totalNumberOfPreGrantedItems) { + mTotalNumberOfPreGrantedItems = totalNumberOfPreGrantedItems; + mSelectedItemSize.postValue(getTotalItemsCount()); } /** @@ -59,51 +156,116 @@ public class Selection { */ public LiveData<Integer> getSelectedItemCount() { if (mSelectedItemSize.getValue() == null) { - mSelectedItemSize.setValue(mSelectedItems.size()); + mSelectedItemSize.setValue(getTotalItemsCount()); } return mSelectedItemSize; } /** + * @return {@link LiveData} of the item selection order. + */ + public LiveData<Integer> getSelectedItemOrder(Item item) { + return mSelectedItemsOrder.get(item.getContentUri()); + } + + private int getTotalItemsCount() { + return mSelectedItems.size() - countOfPreGrantedItems() + mTotalNumberOfPreGrantedItems + - mItemGrantRevocationMap.size(); + } + + /** * Add the selected {@code item} into {@link #mSelectedItems}. */ public void addSelectedItem(Item item) { + if (item.isPreGranted() && mItemGrantRevocationMap.containsKey(item.getId())) { + mItemGrantRevocationMap.remove(item.getId()); + } + if (mIsSelectionOrdered) { + mSelectedItemsOrder.put( + item.getContentUri(), new MutableLiveData(getTotalItemsCount() + 1)); + } mSelectedItems.put(item.getContentUri(), item); - mSelectedItemSize.postValue(mSelectedItems.size()); + mSelectedItemSize.postValue(getTotalItemsCount()); updateSelectionAllowed(); } /** + * Add the checked {@code item} index into {@link #mCheckedItemIndexes}. + */ + public void addCheckedItemIndex(Item item, Integer index) { + mCheckedItemIndexes.put(item, index); + } + + /** * Clears {@link #mSelectedItems} and sets the selected item as given {@code item} */ public void setSelectedItem(Item item) { + mSelectedItemsOrder.clear(); mSelectedItems.clear(); mSelectedItems.put(item.getContentUri(), item); - mSelectedItemSize.postValue(mSelectedItems.size()); + if (mIsSelectionOrdered) { + mSelectedItemsOrder.put( + item.getContentUri(), new MutableLiveData(getTotalItemsCount())); + } + mSelectedItemSize.postValue(getTotalItemsCount()); updateSelectionAllowed(); } /** - * Remove the {@code item} from the selected item list {@link #mSelectedItems}. + * Remove the {@code item} from the selected item list {@link #mSelectedItems} * * @param item the item to be removed from the selected item list */ public void removeSelectedItem(Item item) { + if (item.isPreGranted()) { + // Maintain a list of items that were pre-granted but the user has deselected them in + // the current session. This list will be used to revoke existing grants for these + // items. + mItemGrantRevocationMap.put(item.getId(), item); + } + if (mIsSelectionOrdered) { + MutableLiveData<Integer> removedItem = mSelectedItemsOrder.remove(item.getContentUri()); + int removedItemOrder = removedItem.getValue().intValue(); + mSelectedItemsOrder.values().stream() + .filter(order -> order.getValue().intValue() > removedItemOrder) + .forEach( + order -> { + order.setValue(order.getValue().intValue() - 1); + }); + } mSelectedItems.remove(item.getContentUri()); - mSelectedItemSize.postValue(mSelectedItems.size()); + mSelectedItemSize.postValue(getTotalItemsCount()); updateSelectionAllowed(); } /** - * Clear all selected items + * Remove the {@code item} index from the checked item index list {@link #mCheckedItemIndexes}. + * + * @param item the item to be removed from the selected item list + */ + public void removeCheckedItemIndex(Item item) { + mCheckedItemIndexes.remove(item); + } + + /** + * Clear all selected items and checked positions */ public void clearSelectedItems() { + mSelectedItemsOrder.clear(); mSelectedItems.clear(); - mSelectedItemSize.postValue(mSelectedItems.size()); + mCheckedItemIndexes.clear(); + mSelectedItemSize.postValue(getTotalItemsCount()); updateSelectionAllowed(); } /** + * Clear all checked items + */ + public void clearCheckedItemList() { + mCheckedItemIndexes.clear(); + } + + /** * @return {@code true} if give {@code item} is present in selected items * {@link #mSelectedItems}, {@code false} otherwise */ @@ -113,7 +275,7 @@ public class Selection { private void updateSelectionAllowed() { final int size = mSelectedItems.size(); - if (size >= mMaxSelectionLimit) { + if (size - countOfPreGrantedItems() >= mMaxSelectionLimit) { if (mIsSelectionAllowed) { mIsSelectionAllowed = false; } @@ -125,6 +287,14 @@ public class Selection { } } + private int countOfPreGrantedItems() { + if (mSelectedItems.values() != null) { + return (int) mSelectedItems.values().stream().filter(Item::isPreGranted).count(); + } else { + return 0; + } + } + /** * @return returns whether more items can be selected or not. {@code true} if the number of * selected items is lower than or equal to {@code mMaxLimit}, {@code false} otherwise. @@ -163,11 +333,15 @@ public class Selection { final Bundle extras = intent.getExtras(); final boolean isExtraPickImagesMaxSet = extras != null && extras.containsKey(MediaStore.EXTRA_PICK_IMAGES_MAX); + final boolean isExtraOrderedSelectionSet = + extras != null && extras.containsKey(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER); if (intent.getAction() != null && intent.getAction().equals(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP)) { // If this is picking media for an app, enable multiselect. mSelectMultiple = true; + // disable ordered selection. + mIsSelectionOrdered = false; // Allow selections up to the limit. // TODO(b/255301849): Update max limit after discussing with product team. mMaxSelectionLimit = MediaStore.getPickImagesMaxLimit(); @@ -181,6 +355,11 @@ public class Selection { "EXTRA_PICK_IMAGES_MAX is not supported for " + "ACTION_GET_CONTENT"); } + if (isExtraOrderedSelectionSet) { + throw new IllegalArgumentException( + "EXTRA_PICK_IMAGES_IN_ORDER is not supported for ACTION_GET_CONTENT"); + } + mSelectMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); if (mSelectMultiple) { mMaxSelectionLimit = MediaStore.getPickImagesMaxLimit(); @@ -189,6 +368,10 @@ public class Selection { return; } + if (isExtraOrderedSelectionSet) { + mIsSelectionOrdered = extras.getBoolean(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER); + } + // Check EXTRA_PICK_IMAGES_MAX value only if the flag is set. if (isExtraPickImagesMaxSet) { final int extraMax = @@ -202,6 +385,7 @@ public class Selection { mSelectMultiple = true; mMaxSelectionLimit = extraMax; } + } /** @@ -211,6 +395,11 @@ public class Selection { return mSelectMultiple; } + /** Return whether ordered selection is enabled or not. */ + public boolean isSelectionOrdered() { + return mIsSelectionOrdered; + } + /** * Return maximum limit of items that can be selected */ diff --git a/src/com/android/providers/media/photopicker/data/glide/GlideLoadable.java b/src/com/android/providers/media/photopicker/data/glide/GlideLoadable.java new file mode 100644 index 000000000..7b536a4f5 --- /dev/null +++ b/src/com/android/providers/media/photopicker/data/glide/GlideLoadable.java @@ -0,0 +1,70 @@ +/* + * 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.glide; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.providers.media.photopicker.data.model.Category; +import com.android.providers.media.photopicker.data.model.Item; + +import com.bumptech.glide.signature.ObjectKey; + +import java.util.Optional; + +/** + * A data class to coalesce {@link Item} and {@link Category} into a common loadable glide object, + * with the relevant data required for Glide loading. + */ +public class GlideLoadable { + + private final Optional<String> mCacheKey; + + @NonNull private final Uri mUri; + + public GlideLoadable(@NonNull Uri uri) { + this(uri, /* cacheKey= */ null); + } + + public GlideLoadable(@NonNull Uri uri, @Nullable String cacheKey) { + this.mUri = uri; + this.mCacheKey = Optional.ofNullable(cacheKey); + } + + /** + * Get a signature string to represent this item in the Glide cache. + * + * @param prefix Optional prefix to prepend to this item's signature. + * @return A glide cache signature string. + */ + @Nullable + public ObjectKey getLoadableSignature(@Nullable String prefix) { + return new ObjectKey( + Optional.ofNullable(prefix).orElse("") + mUri.toString() + mCacheKey.orElse("")); + } + ; + + /** + * @return A {@link Uri} object to locate the media for this loadable. + */ + public Uri getLoadableUri() { + return mUri; + } + ; +} diff --git a/src/com/android/providers/media/photopicker/data/glide/PickerGlideModule.java b/src/com/android/providers/media/photopicker/data/glide/PickerGlideModule.java index cc486700c..8ef68d984 100644 --- a/src/com/android/providers/media/photopicker/data/glide/PickerGlideModule.java +++ b/src/com/android/providers/media/photopicker/data/glide/PickerGlideModule.java @@ -17,7 +17,6 @@ package com.android.providers.media.photopicker.data.glide; import android.content.Context; -import android.net.Uri; import com.bumptech.glide.Glide; import com.bumptech.glide.Registry; @@ -34,6 +33,7 @@ public class PickerGlideModule extends AppGlideModule { @Override public void registerComponents(Context context, Glide glide, Registry registry) { - registry.prepend(Uri.class, InputStream.class, new PickerModelLoaderFactory(context)); + registry.append( + GlideLoadable.class, InputStream.class, new PickerModelLoaderFactory(context)); } } diff --git a/src/com/android/providers/media/photopicker/data/glide/PickerModelLoader.java b/src/com/android/providers/media/photopicker/data/glide/PickerModelLoader.java index 1f3bb4cdd..f3816306d 100644 --- a/src/com/android/providers/media/photopicker/data/glide/PickerModelLoader.java +++ b/src/com/android/providers/media/photopicker/data/glide/PickerModelLoader.java @@ -20,7 +20,6 @@ import static com.android.providers.media.photopicker.ui.ImageLoader.THUMBNAIL_R import android.content.Context; import android.content.UriMatcher; -import android.net.Uri; import android.provider.CloudMediaProviderContract; import com.bumptech.glide.load.Options; @@ -29,10 +28,8 @@ import com.bumptech.glide.signature.ObjectKey; import java.io.InputStream; -/** - * Custom {@link ModelLoader} to load thumbnails from cloud media provider. - */ -public final class PickerModelLoader implements ModelLoader<Uri, InputStream> { +/** Custom {@link ModelLoader} to load thumbnails from cloud media provider. */ +public final class PickerModelLoader implements ModelLoader<GlideLoadable, InputStream> { private final Context mContext; PickerModelLoader(Context context) { @@ -40,21 +37,24 @@ public final class PickerModelLoader implements ModelLoader<Uri, InputStream> { } @Override - public LoadData<InputStream> buildLoadData(Uri model, int width, int height, - Options options) { + public LoadData<InputStream> buildLoadData( + GlideLoadable model, int width, int height, Options options) { final boolean isThumbRequest = Boolean.TRUE.equals(options.get(THUMBNAIL_REQUEST)); - return new LoadData<>(new ObjectKey(model), + return new LoadData<>( + new ObjectKey(model.getLoadableSignature(/* prefix= */ null)), new PickerThumbnailFetcher(mContext, model, width, height, isThumbRequest)); } @Override - public boolean handles(Uri model) { + public boolean handles(GlideLoadable model) { final int pickerId = 1; final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH); - matcher.addURI(model.getAuthority(), - CloudMediaProviderContract.URI_PATH_MEDIA + "/*", pickerId); + matcher.addURI( + model.getLoadableUri().getAuthority(), + CloudMediaProviderContract.URI_PATH_MEDIA + "/*", + pickerId); // Matches picker URIs of the form content://<authority>/media - return matcher.match(model) == pickerId; + return matcher.match(model.getLoadableUri()) == pickerId; } } diff --git a/src/com/android/providers/media/photopicker/data/glide/PickerModelLoaderFactory.java b/src/com/android/providers/media/photopicker/data/glide/PickerModelLoaderFactory.java index 938ef882e..d7086d44c 100644 --- a/src/com/android/providers/media/photopicker/data/glide/PickerModelLoaderFactory.java +++ b/src/com/android/providers/media/photopicker/data/glide/PickerModelLoaderFactory.java @@ -17,7 +17,6 @@ package com.android.providers.media.photopicker.data.glide; import android.content.Context; -import android.net.Uri; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoaderFactory; @@ -29,7 +28,7 @@ import java.io.InputStream; * Custom {@link ModelLoaderFactory} which provides a {@link ModelLoader} for loading thumbnails * from cloud media provider. */ -public class PickerModelLoaderFactory implements ModelLoaderFactory<Uri, InputStream> { +public class PickerModelLoaderFactory implements ModelLoaderFactory<GlideLoadable, InputStream> { private final Context mContext; @@ -38,7 +37,7 @@ public class PickerModelLoaderFactory implements ModelLoaderFactory<Uri, InputSt } @Override - public ModelLoader<Uri, InputStream> build(MultiModelLoaderFactory unused) { + public ModelLoader<GlideLoadable, InputStream> build(MultiModelLoaderFactory unused) { return new PickerModelLoader(mContext); } diff --git a/src/com/android/providers/media/photopicker/data/glide/PickerPreloadModelProvider.java b/src/com/android/providers/media/photopicker/data/glide/PickerPreloadModelProvider.java new file mode 100644 index 000000000..9e5b4dd93 --- /dev/null +++ b/src/com/android/providers/media/photopicker/data/glide/PickerPreloadModelProvider.java @@ -0,0 +1,101 @@ +/* + * 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.glide; + +import static com.android.providers.media.photopicker.ui.ImageLoader.THUMBNAIL_REQUEST; + +import static com.bumptech.glide.load.resource.bitmap.Downsampler.PREFERRED_COLOR_SPACE; + +import android.content.Context; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.providers.media.photopicker.data.model.Item; +import com.android.providers.media.photopicker.ui.PhotosTabAdapter; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.ListPreloader.PreloadModelProvider; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.PreferredColorSpace; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.signature.ObjectKey; + +import java.util.Collections; +import java.util.List; + +/** Custom glide module to enable the loading of thumbnails from CloudMediaProvider. */ +public class PickerPreloadModelProvider implements PreloadModelProvider<GlideLoadable> { + + private final Context mContext; + private final PreferredColorSpace mPreferredColorSpace; + private final PhotosTabAdapter mAdapter; + + public PickerPreloadModelProvider(Context context, PhotosTabAdapter adapter) { + mContext = context; + mAdapter = adapter; + + final boolean isScreenWideColorGamut = + mContext.getResources().getConfiguration().isScreenWideColorGamut(); + mPreferredColorSpace = + isScreenWideColorGamut ? PreferredColorSpace.DISPLAY_P3 : PreferredColorSpace.SRGB; + } + + /** + * Return a list of items that should be preloaded for the given RecyclerView adapter position. + * + * @param position the current position of the RecyclerView's adapter. + * @return A list of items to begin preloading. + */ + @Override + @NonNull + public List<GlideLoadable> getPreloadItems(int position) { + if (mAdapter.isItemTypeMediaItem(position)) { + Object adapterItem = mAdapter.getAdapterItem(position); + if (adapterItem instanceof Item) { + Item item = (Item) adapterItem; + return Collections.singletonList(item.toGlideLoadable()); + } + } + return Collections.emptyList(); + } + + /** + * This should generate a load request identical to the load request generated by the + * RecyclerView itself. This ensures that there are not inadvertent cache misses because the + * preload succeeded, but the actual RecyclerView request didn't match what was in the cache + * already. + * + * @param loadable The {@link GlideLoadable} model for the thumbnail. + * @return An identical glide RequestBuilder to what the RecyclerView will generate when it + * attempts to load this item. + */ + @Override + @Nullable + public RequestBuilder getPreloadRequestBuilder(GlideLoadable loadable) { + RequestOptions options = + RequestOptions.option(THUMBNAIL_REQUEST, true) + .set(PREFERRED_COLOR_SPACE, mPreferredColorSpace); + // TODO(b/224725723): Remove media store version from key once MP ids are + // stable. + ObjectKey signature = + loadable.getLoadableSignature(/* prefix= */ MediaStore.getVersion(mContext)); + + return Glide.with(mContext).asBitmap().apply(options).signature(signature).load(loadable); + } +} diff --git a/src/com/android/providers/media/photopicker/data/glide/PickerThumbnailFetcher.java b/src/com/android/providers/media/photopicker/data/glide/PickerThumbnailFetcher.java index 0d8519653..b4ed01a01 100644 --- a/src/com/android/providers/media/photopicker/data/glide/PickerThumbnailFetcher.java +++ b/src/com/android/providers/media/photopicker/data/glide/PickerThumbnailFetcher.java @@ -20,43 +20,46 @@ import android.content.ContentResolver; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.graphics.Point; -import android.net.Uri; import android.os.Bundle; +import android.os.CancellationSignal; import android.provider.CloudMediaProviderContract; +import android.provider.MediaStore; import android.util.Log; -import com.bumptech.glide.Glide; +import androidx.annotation.Nullable; + import com.bumptech.glide.Priority; import com.bumptech.glide.load.DataSource; -import com.bumptech.glide.load.ImageHeaderParserUtils; import com.bumptech.glide.load.data.DataFetcher; -import com.bumptech.glide.load.data.ExifOrientationStream; - import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; /** - * Custom {@link DataFetcher} to fetch a {@link InputStream} for a thumbnail from a cloud - * media provider. + * Custom {@link DataFetcher} to fetch a {@link InputStream} for a thumbnail from a cloud media + * provider. */ public class PickerThumbnailFetcher implements DataFetcher<InputStream> { private static final String TAG = "PickerThumbnailFetcher"; private final Context mContext; - private final Uri mModel; + private final GlideLoadable mModel; private final int mWidth; private final int mHeight; private final boolean mIsThumbRequest; + private final CancellationSignal mCancellationSignal; + @Nullable private AssetFileDescriptor mAssetFileDescriptor = null; + @Nullable private InputStream mInputStream = null; - PickerThumbnailFetcher(Context context, Uri model, int width, int height, - boolean isThumbRequest) { + PickerThumbnailFetcher( + Context context, GlideLoadable model, int width, int height, boolean isThumbRequest) { mContext = context; mModel = model; mWidth = width; mHeight = height; mIsThumbRequest = isThumbRequest; + mCancellationSignal = new CancellationSignal(); } @Override @@ -70,57 +73,50 @@ public class PickerThumbnailFetcher implements DataFetcher<InputStream> { opts.putBoolean(CloudMediaProviderContract.EXTRA_MEDIASTORE_THUMB, true); } - try (AssetFileDescriptor afd = contentResolver.openTypedAssetFileDescriptor(mModel, - /* mimeType */ "image/*", opts, /* cancellationSignal */ null)) { - if (afd == null) { + try { + // Do not close the afd or InputStream as it will close the input stream. The + // afd needs to be closed when cleanup is called, so save a reference so it can + // be closed when Glide is done with it. + mAssetFileDescriptor = + contentResolver.openTypedAssetFileDescriptor( + mModel.getLoadableUri(), + /* mimeType= */ "image/*", + opts, + /* cancellationSignal= */ mCancellationSignal); + if (mAssetFileDescriptor == null) { final String err = "Failed to load data for " + mModel; callback.onLoadFailed(new FileNotFoundException(err)); return; } - - final InputStream inputStream; - if (mIsThumbRequest) { - inputStream = getOrientationInputStream(afd); - } else { - // We don't need to handle orientation for preview requests. Glide load takes care - // of loading the image in the right orientation. - inputStream = afd.createInputStream(); - } - callback.onDataReady(inputStream); + mInputStream = mAssetFileDescriptor.createInputStream(); + callback.onDataReady(mInputStream); } catch (IOException e) { callback.onLoadFailed(e); } } - private InputStream getOrientationInputStream(AssetFileDescriptor afd) throws IOException { - InputStream inputStream = afd.createInputStream(); - - int orientation = -1; - if (inputStream != null) { - try { - orientation = ImageHeaderParserUtils.getOrientation( - Glide.get(mContext).getRegistry().getImageHeaderParsers(), inputStream, - Glide.get(mContext).getArrayPool()); - } catch (IOException | NullPointerException ignored) { - Log.d(TAG, "Unable to fetch orientation for " + mModel, ignored); + /** + * Cleanup is called after Glide is done with this Fetcher instance, and it is now safe to close + * the remembered AssetFileDescriptor. + */ + @Override + public void cleanup() { + try { + if (mInputStream != null) { + mInputStream.close(); } - } - if (orientation != -1) { - inputStream = new ExifOrientationStream(inputStream, orientation); + if (mAssetFileDescriptor != null) { + mAssetFileDescriptor.close(); + } + } catch (IOException e) { + Log.d(TAG, "Unexpected error during thumbnail request cleanup.", e); } - return inputStream; - } - - @Override - public void cleanup() { - // Intentionally empty only because we're not opening an InputStream or another I/O - // resource. } @Override public void cancel() { - // Intentionally empty. + mCancellationSignal.cancel(); } @Override @@ -130,6 +126,13 @@ public class PickerThumbnailFetcher implements DataFetcher<InputStream> { @Override public DataSource getDataSource() { - return DataSource.LOCAL; + // If the authority belongs to MediaProvider, we can consider this a local load. + if (mModel.getLoadableUri().getAuthority().equals(MediaStore.AUTHORITY)) { + return DataSource.LOCAL; + } else { + // Otherwise, let's assume it's a Remote data source so that Glide will cache + // the raw return value rather than manipulated bytes. + return DataSource.REMOTE; + } } } diff --git a/src/com/android/providers/media/photopicker/data/model/Category.java b/src/com/android/providers/media/photopicker/data/model/Category.java index 90a21432a..927a03137 100644 --- a/src/com/android/providers/media/photopicker/data/model/Category.java +++ b/src/com/android/providers/media/photopicker/data/model/Category.java @@ -39,7 +39,9 @@ import androidx.annotation.VisibleForTesting; import com.android.providers.media.R; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.glide.GlideLoadable; +import java.util.List; import java.util.Locale; /** @@ -48,6 +50,9 @@ import java.util.Locale; public class Category { public static final String TAG = "PhotoPicker"; public static final Category DEFAULT = new Category(); + public static final Category EMPTY_VIEW = new Category("EMPTY_VIEW"); + private static final List<String> TRANSLATABLE_CATEGORIES = List.of(ALBUM_ID_VIDEOS, + ALBUM_ID_CAMERA, ALBUM_ID_SCREENSHOTS, ALBUM_ID_DOWNLOADS, ALBUM_ID_FAVORITES); private final String mId; private final String mAuthority; @@ -60,6 +65,9 @@ public class Category { this(null, null, null, null, 0, false); } + private Category(String id) { + this(id, null, null, null, 0, false); + } @VisibleForTesting public Category(String id, String authority, String displayName, Uri coverUri, int itemCount, boolean isLocal) { @@ -74,7 +82,7 @@ public class Category { @Override public String toString() { return String.format(Locale.ROOT, "Category: {mId: %s, mAuthority: %s, mDisplayName: %s, " + - "mCoverUri: %s, mItemCount: %d, mIsLocal: %b", + "mCoverUri: %s, mItemCount: %d, mIsLocal: %b", mId, mAuthority, mDisplayName, mCoverUri, mItemCount, mIsLocal); } @@ -87,7 +95,7 @@ public class Category { } public String getDisplayName(Context context) { - if (mIsLocal) { + if (TRANSLATABLE_CATEGORIES.contains(mId)) { return getLocalizedDisplayName(context, mId); } return mDisplayName; @@ -159,7 +167,7 @@ public class Category { return new Category(getCursorString(cursor, AlbumColumns.ID), authority, getCursorString(cursor, AlbumColumns.DISPLAY_NAME), - coverUri, + getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID) != null ? coverUri : null, getCursorInt(cursor, AlbumColumns.MEDIA_COUNT), isLocal); } @@ -180,4 +188,13 @@ public class Category { return albumId; } } + + /** + * Convert this category into a loadable object for Glide. + * + * @return {@link GlideLoadable} that represents the relevant loadable data for this item. + */ + public GlideLoadable toGlideLoadable() { + return new GlideLoadable(getCoverUri()); + } } diff --git a/src/com/android/providers/media/photopicker/data/model/Item.java b/src/com/android/providers/media/photopicker/data/model/Item.java index eee2e8343..83b0bdbd7 100644 --- a/src/com/android/providers/media/photopicker/data/model/Item.java +++ b/src/com/android/providers/media/photopicker/data/model/Item.java @@ -21,6 +21,7 @@ import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_ANIM import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_GIF; import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_MOTION_PHOTO; +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorInt; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong; import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString; @@ -37,13 +38,23 @@ import androidx.annotation.VisibleForTesting; import com.android.providers.media.R; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.glide.GlideLoadable; import com.android.providers.media.photopicker.util.DateTimeUtils; import com.android.providers.media.util.MimeUtils; +import java.util.Objects; + /** * Base class for representing a single media item (a picture, a video, etc.) in the PhotoPicker. */ public class Item { + public static final Item EMPTY_VIEW = new Item("EMPTY_VIEW"); + public static final String ROW_ID = "row_id"; + + /** + * This id represents the cloud id or the local id of the media, with priority given to cloud id + * if present. + */ private String mId; private long mDateTaken; private long mGenerationModified; @@ -54,6 +65,13 @@ public class Item { private boolean mIsVideo; private int mSpecialFormat; + private boolean mIsPreGranted; + + /** + * This is the row id for the item in the db. + */ + private int mRowId; + public Item(@NonNull Cursor cursor, @NonNull UserId userId) { updateFromCursor(cursor, userId); } @@ -71,6 +89,10 @@ public class Item { parseMimeType(); } + private Item(String id) { + this(id, null, 0, 0, 0, null, 0); + } + public String getId() { return mId; } @@ -124,6 +146,20 @@ public class Item { return mSpecialFormat; } + public int getRowId() { + return mRowId; + } + + /** + * Setting this represents that the item has READ_GRANT for the current package. + */ + public void setPreGranted() { + mIsPreGranted = true; + } + public boolean isPreGranted() { + return mIsPreGranted; + } + public static Item fromCursor(@NonNull Cursor cursor, UserId userId) { return new Item(requireNonNull(cursor), userId); } @@ -143,6 +179,7 @@ public class Item { mDuration = getCursorLong(cursor, MediaColumns.DURATION_MILLIS); mSpecialFormat = getCursorInt(cursor, MediaColumns.STANDARD_MIME_TYPE_EXTENSION); mUri = ItemsProvider.getItemsUri(mId, authority, userId); + mRowId = getCursorInt(cursor, ROW_ID); parseMimeType(); } @@ -196,4 +233,34 @@ public class Item { return mId.compareTo(anotherItem.getId()); } } + + /** + * @return {@code true} iff this item is local (available on device), {@code false} otherwise. + */ + public boolean isLocal() { + return LOCAL_PICKER_PROVIDER_AUTHORITY.equals(mUri.getAuthority()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || !(obj instanceof Item)) return false; + + Item other = (Item) obj; + return mUri.equals(other.mUri); + } + + @Override public int hashCode() { + return Objects.hash(mUri); + } + + /** + * Convert this item into a loadable object for Glide. + * + * @return {@link GlideLoadable} that represents the relevant loadable data for this item. + */ + public GlideLoadable toGlideLoadable() { + return new GlideLoadable(mUri, String.valueOf(getGenerationModified())); + } + } diff --git a/src/com/android/providers/media/photopicker/metrics/NonUiEventLogger.java b/src/com/android/providers/media/photopicker/metrics/NonUiEventLogger.java new file mode 100644 index 000000000..b15d2eda3 --- /dev/null +++ b/src/com/android/providers/media/photopicker/metrics/NonUiEventLogger.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2023 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.metrics; + +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.UiEvent; +import com.android.internal.logging.UiEventLogger; +import com.android.providers.media.metrics.MPUiEventLoggerImpl; + +/** + * Logger for the Non UI Events triggered indirectly by some UI event(s). + */ +public class NonUiEventLogger { + enum NonUiEvent implements UiEventLogger.UiEventEnum { + @UiEvent(doc = "User changed the active Photo picker cloud provider") + PHOTO_PICKER_CLOUD_PROVIDER_CHANGED(1135), + @UiEvent(doc = "Photo Picker uri is queried with an unknown column") + PHOTO_PICKER_QUERY_UNKNOWN_COLUMN(1227), + @UiEvent(doc = "Triggered a full sync in photo picker") + PHOTO_PICKER_FULL_SYNC_START(1442), + @UiEvent(doc = "Triggered an incremental sync in photo picker") + PHOTO_PICKER_INCREMENTAL_SYNC_START(1443), + @UiEvent(doc = "Triggered an album media sync in photo picker") + PHOTO_PICKER_ALBUM_MEDIA_SYNC_START(1444), + @UiEvent(doc = "Triggered get media collection info in photo picker") + PHOTO_PICKER_GET_MEDIA_COLLECTION_INFO_START(1448), + @UiEvent(doc = "Triggered get albums in photo picker") + PHOTO_PICKER_GET_ALBUMS_START(1449), + @UiEvent(doc = "Ended an add media sync in photo picker") + PHOTO_PICKER_ADD_MEDIA_SYNC_END(1445), + @UiEvent(doc = "Ended a remove media sync in photo picker") + PHOTO_PICKER_REMOVE_MEDIA_SYNC_END(1446), + @UiEvent(doc = "Ended an add album media sync in photo picker") + PHOTO_PICKER_ADD_ALBUM_MEDIA_SYNC_END(1447), + @UiEvent(doc = "Ended get media collection info in photo picker") + PHOTO_PICKER_GET_MEDIA_COLLECTION_INFO_END(1450), + @UiEvent(doc = "Ended get albums in photo picker") + PHOTO_PICKER_GET_ALBUMS_END(1451), + @UiEvent(doc = "Read grants added count.") + PHOTO_PICKER_GRANTS_ADDED_COUNT(1528), + @UiEvent(doc = "Read grants revoked count.") + PHOTO_PICKER_GRANTS_REVOKED_COUNT(1529), + @UiEvent(doc = "Total initial grants count.") + PHOTO_PICKER_INIT_GRANTS_COUNT(1530); + + private final int mId; + + NonUiEvent(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + } + + private static final int INSTANCE_ID_MAX = 1 << 15; + private static final InstanceIdSequence INSTANCE_ID_SEQUENCE = + new InstanceIdSequence(INSTANCE_ID_MAX); + private static final UiEventLogger LOGGER = new MPUiEventLoggerImpl(); + + /** + * Generate and {@return} a new unique instance id to group some events for aggregated metrics + */ + public static InstanceId generateInstanceId() { + return INSTANCE_ID_SEQUENCE.newInstanceId(); + } + + /** + * Log metrics to notify that the user has changed the active cloud provider + * @param cloudProviderUid new active cloud provider uid + * @param cloudProviderPackage new active cloud provider package name + */ + public static void logPickerCloudProviderChanged(int cloudProviderUid, + String cloudProviderPackage) { + LOGGER.log(NonUiEvent.PHOTO_PICKER_CLOUD_PROVIDER_CHANGED, cloudProviderUid, + cloudProviderPackage); + } + + /** + * Log metrics to notify that a picker uri was queried for an unknown column (that is not + * supported yet) + * @param callingUid the uid of the app initiating the picker query + * @param callingPackageAndColumn the package name of the app initiating the picker query, + * followed by the unknown column name, separated by a ':' + */ + public static void logPickerQueriedWithUnknownColumn(int callingUid, + String callingPackageAndColumn) { + LOGGER.log(NonUiEvent.PHOTO_PICKER_QUERY_UNKNOWN_COLUMN, callingUid, + callingPackageAndColumn); + } + + /** + * Log metrics to notify that a full sync started + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + */ + public static void logPickerFullSyncStart(InstanceId instanceId, int uid, String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_FULL_SYNC_START, uid, authority, + instanceId); + } + + /** + * Log metrics to notify that an incremental sync started + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + */ + public static void logPickerIncrementalSyncStart(InstanceId instanceId, int uid, + String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_INCREMENTAL_SYNC_START, uid, authority, + instanceId); + } + + /** + * Log metrics to notify that an album media sync started + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + */ + public static void logPickerAlbumMediaSyncStart(InstanceId instanceId, int uid, + String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_ALBUM_MEDIA_SYNC_START, uid, authority, + instanceId); + } + + /** + * Log metrics to notify get media collection info triggered + * @param instanceId an identifier for the current query session + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider + */ + public static void logPickerGetMediaCollectionInfoStart(InstanceId instanceId, int uid, + String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_GET_MEDIA_COLLECTION_INFO_START, uid, + authority, instanceId); + } + + /** + * Log metrics to notify get albums triggered + * @param instanceId an identifier for the current query session + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider + */ + public static void logPickerGetAlbumsStart(InstanceId instanceId, int uid, String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_GET_ALBUMS_START, uid, authority, + instanceId); + } + + /** + * Log metrics to notify that an add media sync ended + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + * @param count the number of items synced + */ + public static void logPickerAddMediaSyncCompletion(InstanceId instanceId, int uid, + String authority, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_ADD_MEDIA_SYNC_END, uid, + authority, instanceId, count); + } + + /** + * Log metrics to notify that a remove media sync ended + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + * @param count the number of items synced + */ + public static void logPickerRemoveMediaSyncCompletion(InstanceId instanceId, int uid, + String authority, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_REMOVE_MEDIA_SYNC_END, uid, + authority, instanceId, count); + } + + /** + * Log metrics to notify that an add album media sync ended + * @param instanceId an identifier for the current sync + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider syncing with + * @param count the number of items synced + */ + public static void logPickerAddAlbumMediaSyncCompletion(InstanceId instanceId, int uid, + String authority, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_ADD_ALBUM_MEDIA_SYNC_END, uid, + authority, instanceId, count); + } + + /** + * Log metrics to notify get media collection info ended + * @param instanceId an identifier for the current query session + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider + */ + public static void logPickerGetMediaCollectionInfoEnd(InstanceId instanceId, int uid, + String authority) { + LOGGER.logWithInstanceId(NonUiEvent.PHOTO_PICKER_GET_MEDIA_COLLECTION_INFO_END, uid, + authority, instanceId); + } + + /** + * Log metrics to notify get albums ended + * @param instanceId an identifier for the current query session + * @param uid the uid of the MediaProvider logging this metric + * @param authority the authority of the provider + * @param count the number of albums fetched + */ + public static void logPickerGetAlbumsEnd(InstanceId instanceId, int uid, String authority, + int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_GET_ALBUMS_END, uid, authority, + instanceId, count); + } + + /** + * Log metrics for count of grants added for a package. + * @param instanceId an identifier for the current session + * @param uid the uid of the MediaProvider logging this metric + * @param packageName the package name receiving the grant. + * @param count the number of items for which the grants have been added. + */ + public static void logPickerChoiceGrantsAdditionCount(InstanceId instanceId, int uid, + String packageName, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_GRANTS_ADDED_COUNT, uid, + packageName, instanceId, count); + } + + /** + * Log metrics for count of grants revoked for a package. + * @param instanceId an identifier for the current session + * @param uid the uid of the MediaProvider logging this metric + * @param packageName the package name for which the grants are being revoked. + * @param count the number of items for which the grants have been revoked. + */ + public static void logPickerChoiceGrantsRemovedCount(InstanceId instanceId, int uid, + String packageName, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_GRANTS_REVOKED_COUNT, uid, + packageName, instanceId, count); + } + + /** + * Log metrics for total count of grants previously added for the package. + * @param instanceId an identifier for the current session + * @param uid the uid of the MediaProvider logging this metric + * @param packageName the package name for which the grants are being initialized. + * @param count the number of items for which the grants have been initialized. + */ + public static void logPickerChoiceInitGrantsCount(InstanceId instanceId, int uid, + String packageName, int count) { + LOGGER.logWithInstanceIdAndPosition(NonUiEvent.PHOTO_PICKER_INIT_GRANTS_COUNT, uid, + packageName, instanceId, count); + } + +} diff --git a/src/com/android/providers/media/photopicker/metrics/PhotoPickerUiEventLogger.java b/src/com/android/providers/media/photopicker/metrics/PhotoPickerUiEventLogger.java index 6368b743c..e127e0599 100644 --- a/src/com/android/providers/media/photopicker/metrics/PhotoPickerUiEventLogger.java +++ b/src/com/android/providers/media/photopicker/metrics/PhotoPickerUiEventLogger.java @@ -16,6 +16,9 @@ package com.android.providers.media.photopicker.metrics; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + import com.android.internal.logging.InstanceId; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; @@ -23,7 +26,8 @@ import com.android.providers.media.metrics.MPUiEventLoggerImpl; public class PhotoPickerUiEventLogger { - enum PhotoPickerEvent implements UiEventLogger.UiEventEnum { + @VisibleForTesting + public enum PhotoPickerEvent implements UiEventLogger.UiEventEnum { @UiEvent(doc = "Photo picker opened in personal profile") PHOTO_PICKER_OPEN_PERSONAL_PROFILE(942), @UiEvent(doc = "Photo picker opened in work profile") @@ -56,10 +60,76 @@ public class PhotoPickerUiEventLogger { PHOTO_PICKER_CONFIRM_PERSONAL_PROFILE(1128), @UiEvent(doc = "Photo picker opened with an active cloud provider") PHOTO_PICKER_CLOUD_PROVIDER_ACTIVE(1198), - @UiEvent(doc = "User changed the active Photo picker cloud provider") - PHOTO_PICKER_CLOUD_PROVIDER_CHANGED(1135), - @UiEvent(doc = "Photo Picker uri is queried with an unknown column") - PHOTO_PICKER_QUERY_UNKNOWN_COLUMN(1227); + @UiEvent(doc = "Clicked the mute / unmute button in a photo picker video preview") + PHOTO_PICKER_VIDEO_PREVIEW_AUDIO_BUTTON_CLICK(1413), + @UiEvent(doc = "Clicked the 'view selected' button in photo picker") + PHOTO_PICKER_PREVIEW_ALL_SELECTED(1414), + @UiEvent(doc = "Photo picker opened with the 'switch profile' button visible and enabled") + PHOTO_PICKER_PROFILE_SWITCH_BUTTON_ENABLED(1415), + @UiEvent(doc = "Photo picker opened with the 'switch profile' button visible but disabled") + PHOTO_PICKER_PROFILE_SWITCH_BUTTON_DISABLED(1416), + @UiEvent(doc = "Clicked the 'switch profile' button in photo picker") + PHOTO_PICKER_PROFILE_SWITCH_BUTTON_CLICK(1417), + @UiEvent(doc = "Exited photo picker by swiping down") + PHOTO_PICKER_EXIT_SWIPE_DOWN(1420), + @UiEvent(doc = "Back pressed in photo picker") + PHOTO_PICKER_BACK_GESTURE(1421), + @UiEvent(doc = "Action bar home button clicked in photo picker") + PHOTO_PICKER_ACTION_BAR_HOME_BUTTON_CLICK(1422), + @UiEvent(doc = "Expanded from half screen to full in photo picker") + PHOTO_PICKER_FROM_HALF_TO_FULL_SCREEN(1423), + @UiEvent(doc = "Photo picker menu opened") + PHOTO_PICKER_MENU(1424), + @UiEvent(doc = "User switched to the photos tab in photo picker") + PHOTO_PICKER_TAB_PHOTOS_OPEN(1425), + @UiEvent(doc = "User switched to the albums tab in photo picker") + PHOTO_PICKER_TAB_ALBUMS_OPEN(1426), + @UiEvent(doc = "Opened the device favorites album in photo picker") + PHOTO_PICKER_ALBUM_FAVORITES_OPEN(1427), + @UiEvent(doc = "Opened the device camera album in photo picker") + PHOTO_PICKER_ALBUM_CAMERA_OPEN(1428), + @UiEvent(doc = "Opened the device downloads album in photo picker") + PHOTO_PICKER_ALBUM_DOWNLOADS_OPEN(1429), + @UiEvent(doc = "Opened the device screenshots album in photo picker") + PHOTO_PICKER_ALBUM_SCREENSHOTS_OPEN(1430), + @UiEvent(doc = "Opened the device videos album in photo picker") + PHOTO_PICKER_ALBUM_VIDEOS_OPEN(1431), + @UiEvent(doc = "Opened a cloud album in photo picker") + PHOTO_PICKER_ALBUM_FROM_CLOUD_OPEN(1432), + @UiEvent(doc = "Selected a media item in the main grid") + PHOTO_PICKER_SELECTED_ITEM_MAIN_GRID(1433), + @UiEvent(doc = "Selected a media item in an album") + PHOTO_PICKER_SELECTED_ITEM_ALBUM(1434), + @UiEvent(doc = "Selected a cloud only media item") + PHOTO_PICKER_SELECTED_ITEM_CLOUD_ONLY(1435), + @UiEvent(doc = "Previewed a media item in the main grid") + PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID(1436), + @UiEvent(doc = "Loaded media items in the main grid in photo picker") + PHOTO_PICKER_UI_LOADED_PHOTOS(1437), + @UiEvent(doc = "Loaded albums in photo picker") + PHOTO_PICKER_UI_LOADED_ALBUMS(1438), + @UiEvent(doc = "Loaded media items in an album grid in photo picker") + PHOTO_PICKER_UI_LOADED_ALBUM_CONTENTS(1439), + @UiEvent(doc = "Triggered create surface controller in photo picker") + PHOTO_PICKER_CREATE_SURFACE_CONTROLLER_START(1452), + @UiEvent(doc = "Ended create surface controller in photo picker") + PHOTO_PICKER_CREATE_SURFACE_CONTROLLER_END(1453), + @UiEvent(doc = "Started the selected media preloading in photo picker") + PHOTO_PICKER_PRELOADING_STARTED(1524), + @UiEvent(doc = "Finished the selected media preloading in photo picker") + PHOTO_PICKER_PRELOADING_FINISHED(1525), + @UiEvent(doc = "User cancelled the selected media preloading in photo picker") + PHOTO_PICKER_PRELOADING_CANCELLED(1526), + @UiEvent(doc = "Failed to preload some selected media items in photo picker") + PHOTO_PICKER_PRELOADING_FAILED(1527), + @UiEvent(doc = "The banner is added to display in the recycler view grids in photo picker") + PHOTO_PICKER_BANNER_ADDED(1539), + @UiEvent(doc = "The user clicks the dismiss button of the banner in photo picker") + PHOTO_PICKER_BANNER_DISMISSED(1540), + @UiEvent(doc = "The user clicks the action button of the banner in photo picker") + PHOTO_PICKER_BANNER_ACTION_BUTTON_CLICKED(1541), + @UiEvent(doc = "The user clicks on the remaining part of the banner in photo picker") + PHOTO_PICKER_BANNER_CLICKED(1542); private final int mId; @@ -79,6 +149,11 @@ public class PhotoPickerUiEventLogger { logger = new MPUiEventLoggerImpl(); } + @VisibleForTesting + public PhotoPickerUiEventLogger(@NonNull UiEventLogger logger) { + this.logger = logger; + } + public void logPickerOpenPersonal(InstanceId instanceId, int callingUid, String callingPackage) { logger.logWithInstanceId( @@ -293,26 +368,334 @@ public class PhotoPickerUiEventLogger { } /** - * Log metrics to notify that the user has changed the active cloud provider - * @param cloudProviderUid new active cloud provider uid - * @param cloudProviderPackage new active cloud provider package name + * Log metrics to notify that the user has clicked the mute / unmute button in a video preview + * @param instanceId an identifier for the current picker session */ - public void logPickerCloudProviderChanged(int cloudProviderUid, String cloudProviderPackage) { - logger.log(PhotoPickerEvent.PHOTO_PICKER_CLOUD_PROVIDER_CHANGED, cloudProviderUid, - cloudProviderPackage); + public void logVideoPreviewMuteButtonClick(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_VIDEO_PREVIEW_AUDIO_BUTTON_CLICK, instanceId); } /** - * Log metrics to notify that a picker uri was queried for an unknown column (that is not - * supported yet) - * @param callingUid the uid of the app initiating the picker query - * @param callingPackage the package name of the app initiating the picker query - * - * TODO(b/251425380): Move non-UI events out of PhotoPickerUiEventLogger + * Log metrics to notify that the user has clicked the 'view selected' button + * @param instanceId an identifier for the current picker session + * @param selectedItemCount the number of items selected for preview all */ - public void logPickerQueriedWithUnknownColumn(int callingUid, String callingPackage) { - logger.log(PhotoPickerEvent.PHOTO_PICKER_QUERY_UNKNOWN_COLUMN, - callingUid, - callingPackage); + public void logPreviewAllSelected(InstanceId instanceId, int selectedItemCount) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ALL_SELECTED, instanceId, + selectedItemCount); + } + + /** + * Log metrics to notify that the 'switch profile' button is visible & enabled + * @param instanceId an identifier for the current picker session + */ + public void logProfileSwitchButtonEnabled(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_ENABLED, instanceId); + } + + /** + * Log metrics to notify that the 'switch profile' button is visible but disabled + * @param instanceId an identifier for the current picker session + */ + public void logProfileSwitchButtonDisabled(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_DISABLED, instanceId); + } + + /** + * Log metrics to notify that the user has clicked the 'switch profile' button + * @param instanceId an identifier for the current picker session + */ + public void logProfileSwitchButtonClick(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_CLICK, instanceId); + } + + /** + * Log metrics to notify that the user has cancelled the current session by swiping down + * @param instanceId an identifier for the current picker session + */ + public void logSwipeDownExit(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_EXIT_SWIPE_DOWN, instanceId); + } + + /** + * Log metrics to notify that the user has made a back gesture + * @param instanceId an identifier for the current picker session + * @param backStackEntryCount the number of fragment entries currently in the back stack + */ + public void logBackGestureWithStackCount(InstanceId instanceId, int backStackEntryCount) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_BACK_GESTURE, instanceId, + backStackEntryCount); + } + + /** + * Log metrics to notify that the user has clicked the action bar home button + * @param instanceId an identifier for the current picker session + * @param backStackEntryCount the number of fragment entries currently in the back stack + */ + public void logActionBarHomeButtonClick(InstanceId instanceId, int backStackEntryCount) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_ACTION_BAR_HOME_BUTTON_CLICK, + instanceId, backStackEntryCount); + } + + /** + * Log metrics to notify that the user has expanded from half screen to full + * @param instanceId an identifier for the current picker session + */ + public void logExpandToFullScreen(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_FROM_HALF_TO_FULL_SCREEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the photo picker menu + * @param instanceId an identifier for the current picker session + */ + public void logMenuOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_MENU, instanceId); + } + + /** + * Log metrics to notify that the user has switched to the photos tab + * @param instanceId an identifier for the current picker session + */ + public void logSwitchToPhotosTab(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_TAB_PHOTOS_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has switched to the albums tab + * @param instanceId an identifier for the current picker session + */ + public void logSwitchToAlbumsTab(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_TAB_ALBUMS_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the device favorites album + * @param instanceId an identifier for the current picker session + */ + public void logFavoritesAlbumOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_ALBUM_FAVORITES_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the device camera album + * @param instanceId an identifier for the current picker session + */ + public void logCameraAlbumOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_ALBUM_CAMERA_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the device downloads album + * @param instanceId an identifier for the current picker session + */ + public void logDownloadsAlbumOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_ALBUM_DOWNLOADS_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the device screenshots album + * @param instanceId an identifier for the current picker session + */ + public void logScreenshotsAlbumOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_ALBUM_SCREENSHOTS_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened the device videos album + * @param instanceId an identifier for the current picker session + */ + public void logVideosAlbumOpened(InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_ALBUM_VIDEOS_OPEN, instanceId); + } + + /** + * Log metrics to notify that the user has opened a cloud album + * @param instanceId an identifier for the current picker session + * @param position the position of the album in the recycler view + */ + public void logCloudAlbumOpened(InstanceId instanceId, int position) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_ALBUM_FROM_CLOUD_OPEN, instanceId, + position); + } + + /** + * Log metrics to notify that the user selected a media item in the main grid + * @param instanceId an identifier for the current picker session + * @param position the position of the album in the recycler view + */ + public void logSelectedMainGridItem(InstanceId instanceId, int position) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_MAIN_GRID, + instanceId, position); + } + + /** + * Log metrics to notify that the user selected a media item in an album + * @param instanceId an identifier for the current picker session + * @param position the position of the album in the recycler view + */ + public void logSelectedAlbumItem(InstanceId instanceId, int position) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_ALBUM, instanceId, + position); + } + + /** + * Log metrics to notify that the user has selected a cloud only media item + * @param instanceId an identifier for the current picker session + * @param position the position of the album in the recycler view + */ + public void logSelectedCloudOnlyItem(InstanceId instanceId, int position) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_CLOUD_ONLY, + instanceId, position); + } + + /** + * Log metrics to notify that the user has previewed an item in the main grid + * @param specialFormat the special format of the previewed item (used to identify special + * categories like motion photos) + * @param mimeType the mime type of the previewed item + * @param instanceId an identifier for the current picker session + * @param position the position of the album in the recycler view + */ + public void logPreviewedMainGridItem( + int specialFormat, String mimeType, InstanceId instanceId, int position) { + logger.logWithInstanceIdAndPosition(PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + specialFormat, mimeType, instanceId, position); + } + + /** + * Log metrics to notify that the picker has loaded some media items in the main grid + * @param authority the authority of the selected cloud provider, null if no non-local items + * @param instanceId an identifier for the current picker session + * @param count the number of media items loaded + */ + public void logLoadedMainGridMediaItems(String authority, InstanceId instanceId, int count) { + logger.logWithInstanceIdAndPosition(PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_PHOTOS, + /* uid */ 0, authority, instanceId, count); + } + + /** + * Log metrics to notify that the picker has loaded some albums + * @param authority the authority of the selected cloud provider, null if no non-local albums + * @param instanceId an identifier for the current picker session + * @param count the number of albums loaded + */ + public void logLoadedAlbums(String authority, InstanceId instanceId, int count) { + logger.logWithInstanceIdAndPosition(PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_ALBUMS, + /* uid */ 0, authority, instanceId, count); + } + + /** + * Log metrics to notify that the picker has loaded some media items in an album grid + * @param authority the authority of the selected cloud provider, null if no non-local items + * @param instanceId an identifier for the current picker session + * @param count the number of media items loaded + */ + public void logLoadedAlbumGridMediaItems(String authority, InstanceId instanceId, int count) { + logger.logWithInstanceIdAndPosition(PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_ALBUM_CONTENTS, + /* uid */ 0, authority, instanceId, count); + } + + /** + * Log metrics to notify create surface controller triggered + * @param instanceId an identifier for the current picker session + * @param authority the authority of the provider + */ + public void logPickerCreateSurfaceControllerStart(InstanceId instanceId, String authority) { + logger.logWithInstanceId(PhotoPickerEvent.PHOTO_PICKER_CREATE_SURFACE_CONTROLLER_START, + /* uid */ 0, authority, instanceId); + } + + /** + * Log metrics to notify create surface controller ended + * @param instanceId an identifier for the current picker session + * @param authority the authority of the provider + */ + public void logPickerCreateSurfaceControllerEnd(InstanceId instanceId, String authority) { + logger.logWithInstanceId(PhotoPickerEvent.PHOTO_PICKER_CREATE_SURFACE_CONTROLLER_END, + /* uid */ 0, authority, instanceId); + } + + /** + * Log metrics to notify that the picker has started preloading the selected media items + * @param instanceId an identifier for the current picker session + * @param count the number of items to be preloaded + */ + public void logPreloadingStarted(@NonNull InstanceId instanceId, int count) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_PRELOADING_STARTED, instanceId, + count); + } + + /** + * Log metrics to notify that the picker has finished preloading the selected media items + * @param instanceId an identifier for the current picker session + */ + public void logPreloadingFinished(@NonNull InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_PRELOADING_FINISHED, instanceId); + } + + /** + * Log metrics to notify that the user cancelled the selected media preloading + * @param instanceId an identifier for the current picker session + * @param count the number of items pending to preload + */ + public void logPreloadingCancelled(@NonNull InstanceId instanceId, int count) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_PRELOADING_CANCELLED, instanceId, + count); + } + + /** + * Log metrics to notify that the selected media preloading failed for some items + * @param instanceId an identifier for the current picker session + * @param count the number of items pending / failed to preload + */ + public void logPreloadingFailed(@NonNull InstanceId instanceId, int count) { + logWithInstanceAndPosition(PhotoPickerEvent.PHOTO_PICKER_PRELOADING_FAILED, instanceId, + count); + } + + /** + * Log metrics to notify that the banner is added to display in the recycler view grids + * @param instanceId an identifier for the current picker session + * @param bannerName the name of the banner added, + * refer {@link com.android.providers.media.photopicker.ui.TabAdapter.Banner} + */ + public void logBannerAdded(@NonNull InstanceId instanceId, @NonNull String bannerName) { + logger.logWithInstanceId(PhotoPickerEvent.PHOTO_PICKER_BANNER_ADDED, /* uid= */ 0, + bannerName, instanceId); + } + + /** + * Log metrics to notify that the banner is dismissed by the user + * @param instanceId an identifier for the current picker session + */ + public void logBannerDismissed(@NonNull InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_BANNER_DISMISSED, instanceId); + } + + /** + * Log metrics to notify that the user clicked the banner action button + * @param instanceId an identifier for the current picker session + */ + public void logBannerActionButtonClicked(@NonNull InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_BANNER_ACTION_BUTTON_CLICKED, instanceId); + } + + /** + * Log metrics to notify that the user clicked on the remaining part of the banner + * @param instanceId an identifier for the current picker session + */ + public void logBannerClicked(@NonNull InstanceId instanceId) { + logWithInstance(PhotoPickerEvent.PHOTO_PICKER_BANNER_CLICKED, instanceId); + } + + private void logWithInstance(@NonNull UiEventLogger.UiEventEnum event, InstanceId instance) { + logger.logWithInstanceId(event, /* uid */ 0, /* packageName */ null, instance); + } + + private void logWithInstanceAndPosition(@NonNull UiEventLogger.UiEventEnum event, + @NonNull InstanceId instance, int position) { + logger.logWithInstanceIdAndPosition(event, /* uid= */ 0, /* packageName= */ null, instance, + position); } } diff --git a/src/com/android/providers/media/photopicker/sync/CloseableReentrantLock.java b/src/com/android/providers/media/photopicker/sync/CloseableReentrantLock.java new file mode 100644 index 000000000..bcd54e45b --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/CloseableReentrantLock.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2023 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.sync; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A Reentrant lock that implements AutoCloseable interface. + */ +public class CloseableReentrantLock extends ReentrantLock implements AutoCloseable { + private static final String TAG = CloseableReentrantLock.class.getSimpleName(); + private final String mLockName; + + public CloseableReentrantLock(@NonNull String lockName) { + super(); + mLockName = lockName; + } + + /** + * Try to acquire lock with a timeout after running some validations. + */ + public CloseableReentrantLock lockWithTimeout(long timeout, TimeUnit unit) + throws UnableToAcquireLockException { + try { + final boolean success = + this.tryLock(timeout, unit); + if (!success) { + throw new UnableToAcquireLockException( + "Could not acquire the lock within timeout " + this); + } + Log.d(TAG, "Successfully acquired lock " + this); + return this; + } catch (InterruptedException e) { + throw new UnableToAcquireLockException( + "Interrupted while waiting for lock " + this, e); + } + } + + @Override + public void close() { + unlock(); + } + + /** + * Attempt to release the lock and swallow IllegalMonitorStateException, if thrown. + */ + @Override + public void lock() { + super.lock(); + Log.d(TAG, "Successfully acquired lock " + this); + } + + /** + * Attempt to release the lock and swallow IllegalMonitorStateException, if thrown. + */ + @Override + public void unlock() { + try { + super.unlock(); + Log.d(TAG, "Successfully released lock " + this); + } catch (IllegalMonitorStateException e) { + Log.e(TAG, "Tried to release a lock that is not held by this thread - " + this); + } + } + + @Override + public String toString() { + return super.toString() + ". Lock Name = " + mLockName + + ". Threads that may be waiting to acquire this lock = " + getQueuedThreads(); + } +} diff --git a/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorker.java b/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorker.java new file mode 100644 index 000000000..71cd5b397 --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorker.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2023 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.sync; + + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_ALBUM_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markAlbumMediaSyncAsComplete; + +import android.content.Context; +import android.os.CancellationSignal; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.work.ForegroundInfo; +import androidx.work.ListenableWorker; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException; + +/** + * This is a {@link Worker} class responsible for syncing album media with the correct sync source. + */ +public class ImmediateAlbumSyncWorker extends Worker { + private static final String TAG = "IASyncWorker"; + private static final int INVALID_SYNC_SOURCE = -1; + private final Context mContext; + private final CancellationSignal mCancellationSignal = new CancellationSignal(); + + /** + * Creates an instance of the {@link Worker}. + * + * @param context the application {@link Context} + * @param workerParams the set of {@link WorkerParameters} + */ + public ImmediateAlbumSyncWorker( + @NonNull Context context, + @NonNull WorkerParameters workerParams) { + super(context, workerParams); + mContext = context; + } + + @NonNull + @Override + public ListenableWorker.Result doWork() { + // Do not allow endless re-runs of this worker, if this isn't the original run, + // just succeed and wait until the next scheduled run. + if (getRunAttemptCount() > 0) { + Log.w(TAG, "Worker retry was detected, ending this run in failure."); + return ListenableWorker.Result.failure(); + } + final int syncSource = getInputData() + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ INVALID_SYNC_SOURCE); + final String albumId = getInputData().getString(SYNC_WORKER_INPUT_ALBUM_ID); + + Log.i(TAG, String.format( + "Starting picker immediate album sync from sync source: %s album id: %s", + syncSource, albumId)); + + try { + validateWorkInput(syncSource, albumId); + + // No need to instantiate a work request tracker for immediate syncs in the worker. + // For immediate syncs, the work request tracker is initiated before enqueueing the + // request in WorkManager. + checkIsWorkerStopped(); + if (syncSource == SYNC_LOCAL_ONLY) { + PickerSyncController.getInstanceOrThrow() + .syncAlbumMediaFromLocalProvider(albumId, mCancellationSignal); + } else { + PickerSyncController.getInstanceOrThrow() + .syncAlbumMediaFromCloudProvider(albumId, mCancellationSignal); + } + + Log.i(TAG, String.format( + "Completed picker immediate album sync from sync source: %s album id: %s", + syncSource, albumId)); + return ListenableWorker.Result.success(); + } catch (IllegalArgumentException | IllegalStateException | RequestObsoleteException e) { + Log.e(TAG, String.format("Could not complete picker immediate album sync from " + + "sync source: %s album id: %s", + syncSource, albumId), e); + return ListenableWorker.Result.failure(); + } finally { + markAlbumMediaSyncAsComplete(syncSource, getId()); + } + } + + /** + * Validates input data received by the Worker for an immediate album sync. + */ + private void validateWorkInput(int syncSource, @Nullable String albumId) + throws IllegalArgumentException { + // Album syncs can only happen with either local provider or cloud provider. This + // information needs to be provided in the {@code inputData}. + if (syncSource != SYNC_LOCAL_ONLY && syncSource != SYNC_CLOUD_ONLY) { + throw new IllegalArgumentException("Invalid album sync source " + syncSource); + } + if (albumId == null || TextUtils.isEmpty(albumId)) { + throw new IllegalArgumentException("Invalid album id " + albumId); + } + } + + private void checkIsWorkerStopped() throws RequestObsoleteException { + if (isStopped()) { + throw new RequestObsoleteException("Work is stopped " + getId()); + } + } + + @Override + @NonNull + public ForegroundInfo getForegroundInfo() { + return PickerSyncNotificationHelper.getForegroundInfo(mContext); + } + + @Override + public void onStopped() { + Log.w(TAG, "Worker is stopped. Clearing all pending futures. It's possible that the sync " + + "will continue to run if it has started already."); + // Send CancellationSignal to any running tasks. + mCancellationSignal.cancel(); + final int syncSource = getInputData() + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ SYNC_LOCAL_ONLY); + markAlbumMediaSyncAsComplete(syncSource, getId()); + } + + @VisibleForTesting + CancellationSignal getCancellationSignal() { + return mCancellationSignal; + } +} diff --git a/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorker.java b/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorker.java new file mode 100644 index 000000000..a284d313f --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorker.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2023 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.sync; + + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.getCloudSyncTracker; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.getLocalSyncTracker; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markSyncAsComplete; + +import android.content.Context; +import android.os.CancellationSignal; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.work.ForegroundInfo; +import androidx.work.ListenableWorker; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException; + +/** + * This is a {@link Worker} class responsible for syncing with the correct sync source. + */ +public class ImmediateSyncWorker extends Worker { + private static final String TAG = "ISyncWorker"; + private final Context mContext; + private final CancellationSignal mCancellationSignal = new CancellationSignal(); + + /** + * Creates an instance of the {@link Worker}. + * + * @param context the application {@link Context} + * @param workerParams the set of {@link WorkerParameters} + */ + public ImmediateSyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + mContext = context; + } + + @NonNull + @Override + public ListenableWorker.Result doWork() { + // Do not allow endless re-runs of this worker, if this isn't the original run, + // just succeed and wait until the next scheduled run. + if (getRunAttemptCount() > 0) { + Log.w(TAG, "Worker retry was detected, ending this run in failure."); + return ListenableWorker.Result.failure(); + } + final int syncSource = getInputData() + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ SYNC_LOCAL_ONLY); + + Log.i(TAG, String.format( + "Starting immediate picker sync from sync source: %s", syncSource)); + + try { + // No need to instantiate a work request tracker for immediate syncs in the worker. + // For immediate syncs, the work request tracker is initiated before enqueueing the + // request in WorkManager. + if (syncSource == SYNC_LOCAL_AND_CLOUD || syncSource == SYNC_LOCAL_ONLY) { + checkIsWorkerStopped(); + PickerSyncController.getInstanceOrThrow() + .syncAllMediaFromLocalProvider(mCancellationSignal); + getLocalSyncTracker().markSyncCompleted(getId()); + Log.i(TAG, "Completed immediate picker sync from local provider."); + } + if (syncSource == SYNC_LOCAL_AND_CLOUD || syncSource == SYNC_CLOUD_ONLY) { + checkIsWorkerStopped(); + PickerSyncController.getInstanceOrThrow() + .syncAllMediaFromCloudProvider(mCancellationSignal); + getCloudSyncTracker().markSyncCompleted(getId()); + Log.i(TAG, "Completed immediate picker sync from cloud provider."); + } + return ListenableWorker.Result.success(); + } catch (IllegalStateException | RequestObsoleteException e) { + Log.i(TAG, String.format( + "Could not complete immediate sync from sync source: %s", syncSource), e); + + // Mark all pending syncs as finished and set failure result. + markSyncAsComplete(syncSource, getId()); + return ListenableWorker.Result.failure(); + } + } + + private void checkIsWorkerStopped() throws RequestObsoleteException { + if (isStopped()) { + throw new RequestObsoleteException("Work is stopped " + getId()); + } + } + + @Override + @NonNull + public ForegroundInfo getForegroundInfo() { + return PickerSyncNotificationHelper.getForegroundInfo(mContext); + } + + @Override + public void onStopped() { + Log.w(TAG, "Worker is stopped. Clearing all pending futures. It's possible that the sync " + + "still finishes running if it has started already."); + // Send CancellationSignal to any running tasks. + mCancellationSignal.cancel(); + final int syncSource = getInputData() + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ SYNC_LOCAL_AND_CLOUD); + markSyncAsComplete(syncSource, getId()); + } + + @VisibleForTesting + CancellationSignal getCancellationSignal() { + return mCancellationSignal; + } +} diff --git a/src/com/android/providers/media/photopicker/sync/MediaResetWorker.java b/src/com/android/providers/media/photopicker/sync/MediaResetWorker.java new file mode 100644 index 000000000..1558b9f0e --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/MediaResetWorker.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_RESET_ALBUM; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_RESET_MEDIA; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_ALBUM_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_RESET_TYPE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_TAG_IS_PERIODIC; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markAlbumMediaSyncAsComplete; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.trackNewAlbumMediaSyncRequests; + +import android.content.Context; +import android.database.sqlite.SQLiteException; +import android.os.Trace; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.work.ForegroundInfo; +import androidx.work.ListenableWorker; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncManager.SyncResetType; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; + +/** + * This is a {@link Worker} class responsible for handling table reset operations in the picker + * database. + */ +public class MediaResetWorker extends Worker { + + private static final String TAG = "MediaResetWorker"; + private static final int UNDEFINED_RESET_TYPE = -1; + + @Nullable private final String mAlbumId; + @NonNull private final Context mContext; + @NonNull private final int mResetType; + @NonNull private final int mSyncSource; + + @Nullable private String mAuthority; + + public MediaResetWorker(@NonNull Context context, @NonNull WorkerParameters workerParameters) { + super(context, workerParameters); + mContext = context; + + mAuthority = getInputData().getString(SYNC_WORKER_INPUT_AUTHORITY); + mAlbumId = getInputData().getString(SYNC_WORKER_INPUT_ALBUM_ID); + mResetType = getInputData().getInt(SYNC_WORKER_INPUT_RESET_TYPE, UNDEFINED_RESET_TYPE); + mSyncSource = getInputData().getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1); + } + + @Override + public ListenableWorker.Result doWork() { + Log.i( + TAG, + String.format( + "MediaReset has been requested. Authority: %s AlbumId: %s", + mAuthority, mAlbumId)); + + PickerSyncController controller; + PickerDbFacade dBFacade; + PickerSyncLockManager pickerSyncLockManager; + try { + controller = PickerSyncController.getInstanceOrThrow(); + pickerSyncLockManager = controller.getPickerSyncLockManager(); + dBFacade = new PickerDbFacade(mContext, pickerSyncLockManager); + } catch (IllegalStateException ex) { + Log.e(TAG, "Unable to obtain PickerSyncController", ex); + return ListenableWorker.Result.failure(); + } catch (SQLiteException ex) { + Log.e(TAG, "Unable to get writeable database", ex); + return ListenableWorker.Result.failure(); + } + + + try { + if (getTags().contains(SYNC_WORKER_TAG_IS_PERIODIC)) { + // If this worker is being run as part of periodic work, it needs to register + // its own sync with the sync tracker. + trackNewAlbumMediaSyncRequests(mSyncSource, getId()); + + // Since this is a periodic worker, we'll use the cloud authority, if it exists. + // Using the cloud authority will reset files for all providers. If the local + // authority is used, it will limit the query to only files with a local_id, but + // the cloud authority does not have such a limitation. + // (This is not intuitive, it's just how it works.) + mAuthority = controller.getCloudProviderWithTimeout(); + if (mAuthority == null) { + mAuthority = controller.getLocalProvider(); + } + // If the authority is still null, end the operation. + if (mAuthority == null) { + Log.e(TAG, "Unable to set authority for periodic worker"); + return ListenableWorker.Result.failure(); + } + } + + if (mSyncSource == SYNC_LOCAL_ONLY) { + return start(dBFacade); + } else { + // SyncSource is either CLOUD_ONLY or LOCAL_AND_CLOUD, either way we need the + // cloud lock. + try (CloseableReentrantLock ignored = pickerSyncLockManager + .tryLock(PickerSyncLockManager.CLOUD_ALBUM_SYNC_LOCK)) { + return start(dBFacade); + } + } + } catch (UnableToAcquireLockException e) { + Log.e(TAG, "Could not acquire lock", e); + return ListenableWorker.Result.failure(); + } finally { + markAlbumMediaSyncAsComplete(mSyncSource, getId()); + } + } + + private ListenableWorker.Result start(@NonNull PickerDbFacade dbFacade) { + + Trace.beginSection("MediaResetWorker:BeginOperation"); + + int deleteCount = 0; + try (PickerDbFacade.DbWriteOperation operation = + beginResetOperation(dbFacade, mResetType)) { + + deleteCount = operation.execute(/* cursor= */ null); + + // Just ensure the worker hasn't been stopped before allowing the commit. + if (isStopped()) { + Log.i(TAG, "Worker was stopped before operation was completed"); + return ListenableWorker.Result.failure(); + } + operation.setSuccess(); + + } catch (UnsupportedOperationException | IllegalStateException ex) { + Log.e(TAG, "Operation failed.", ex); + return ListenableWorker.Result.failure(); + } finally { + Trace.endSection(); + } + + Log.i(TAG, String.format("Reset operation complete. Deleted rows: %d", deleteCount)); + return ListenableWorker.Result.success(); + } + + private PickerDbFacade.DbWriteOperation beginResetOperation( + @NonNull PickerDbFacade dbFacade, @NonNull @SyncResetType int resetType) { + + switch (resetType) { + case SYNC_RESET_ALBUM: + if (mAuthority == null) { + throw new IllegalStateException( + String.format( + "Failed to begin SYNC_RESET_ALBUM. Unknown provider authority:" + + " %s", + mAuthority)); + } + + if (mSyncSource == SYNC_CLOUD_ONLY && mAlbumId == null) { + Log.w( + TAG, + "Sync Source is set to SYNC_CLOUD_ONLY with no albumId, but the reset" + + " operation will still remove cloud+local files."); + } + return dbFacade.beginResetAlbumMediaOperation(mAuthority, mAlbumId); + case SYNC_RESET_MEDIA: + default: + throw new UnsupportedOperationException( + String.format( + "Requested Reset operation not (yet) supported. ResetType: %d", + resetType)); + } + } + + @Override + @NonNull + public ForegroundInfo getForegroundInfo() { + return PickerSyncNotificationHelper.getForegroundInfo(mContext); + } + + @Override + public void onStopped() { + Log.w( + TAG, + "Worker is stopped. Clearing all pending futures. It's possible that the worker " + + "still finishes running if it has started already."); + markAlbumMediaSyncAsComplete(mSyncSource, getId()); + } +} diff --git a/src/com/android/providers/media/photopicker/sync/PickerSyncLockManager.java b/src/com/android/providers/media/photopicker/sync/PickerSyncLockManager.java new file mode 100644 index 000000000..f01026f0b --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/PickerSyncLockManager.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 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.sync; + +import android.annotation.IntDef; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Manages Java locks acquired during the sync process to ensure that the cloud sync is thread safe. + */ +public class PickerSyncLockManager { + private static final String TAG = PickerSyncLockManager.class.getSimpleName(); + private static final Integer LOCK_ACQUIRE_TIMEOUT_MINS = 4; + private static final TimeUnit LOCK_ACQUIRE_TIMEOUT_UNIT = TimeUnit.MINUTES; + + @IntDef(value = {CLOUD_SYNC_LOCK, CLOUD_ALBUM_SYNC_LOCK, CLOUD_PROVIDER_LOCK, DB_CLOUD_LOCK}) + @Retention(RetentionPolicy.SOURCE) + public @interface LockType {} + public static final int CLOUD_SYNC_LOCK = 0; + public static final int CLOUD_ALBUM_SYNC_LOCK = 1; + public static final int CLOUD_PROVIDER_LOCK = 2; + public static final int DB_CLOUD_LOCK = 3; + + private final CloseableReentrantLock mCloudSyncLock = + new CloseableReentrantLock("CLOUD_SYNC_LOCK"); + private final CloseableReentrantLock mCloudAlbumSyncLock = + new CloseableReentrantLock("CLOUD_ALBUM_SYNC_LOCK"); + private final CloseableReentrantLock mCloudProviderLock = + new CloseableReentrantLock("CLOUD_PROVIDER_LOCK"); + private final CloseableReentrantLock mDbCloudLock = + new CloseableReentrantLock("DB_CLOUD_LOCK"); + + /** + * Try to acquire lock with a default timeout after running some validations. + */ + public CloseableReentrantLock tryLock(@LockType int lockType) + throws UnableToAcquireLockException { + return tryLock(lockType, LOCK_ACQUIRE_TIMEOUT_MINS, LOCK_ACQUIRE_TIMEOUT_UNIT); + } + + /** + * Try to acquire lock with the provided timeout after running some validations. + */ + public CloseableReentrantLock tryLock(@LockType int lockType, long timeout, TimeUnit unit) + throws UnableToAcquireLockException { + return tryLock(getLock(lockType), timeout, unit); + } + + /** + * Try to acquire the given lock with the provided timeout after running some validations. + */ + @VisibleForTesting + public CloseableReentrantLock tryLock(@NonNull CloseableReentrantLock lock, + long timeout, TimeUnit unit) throws UnableToAcquireLockException { + Log.d(TAG, "Trying to acquire lock " + lock + " with timeout."); + validateLockOrder(lock); + return lock.lockWithTimeout(timeout, unit); + } + + /** + * Try to acquire the lock after running some validations. + */ + public CloseableReentrantLock lock(@LockType int lockType) { + final CloseableReentrantLock reentrantLock = getLock(lockType); + Log.d(TAG, "Trying to acquire lock " + reentrantLock); + validateLockOrder(reentrantLock); + reentrantLock.lock(); + return reentrantLock; + } + + /** + * Return the {@link CloseableReentrantLock} corresponding to the given {@link LockType}. + * Throws a {@link RuntimeException} if the lock is not recognized. + */ + @VisibleForTesting + public CloseableReentrantLock getLock(@LockType int lockType) { + switch (lockType) { + case CLOUD_SYNC_LOCK: + return mCloudSyncLock; + case CLOUD_ALBUM_SYNC_LOCK: + return mCloudAlbumSyncLock; + case CLOUD_PROVIDER_LOCK: + return mCloudProviderLock; + case DB_CLOUD_LOCK: + return mDbCloudLock; + default: + throw new RuntimeException("Unrecognizable lock type " + lockType); + } + } + + private void validateLockOrder(@NonNull ReentrantLock lockToBeAcquired) { + if (lockToBeAcquired.equals(mCloudSyncLock)) { + validateLockOrder(lockToBeAcquired, mCloudAlbumSyncLock); + validateLockOrder(lockToBeAcquired, mCloudProviderLock); + validateLockOrder(lockToBeAcquired, mDbCloudLock); + } else if (lockToBeAcquired.equals(mCloudAlbumSyncLock)) { + validateLockOrder(lockToBeAcquired, mCloudSyncLock); + validateLockOrder(lockToBeAcquired, mCloudProviderLock); + validateLockOrder(lockToBeAcquired, mDbCloudLock); + } else if (lockToBeAcquired.equals(mCloudProviderLock)) { + validateLockOrder(lockToBeAcquired, mDbCloudLock); + } + } + + private void validateLockOrder(@NonNull ReentrantLock lockToBeAcquired, + @NonNull ReentrantLock lockThatShouldNotBeHeld) { + if (lockThatShouldNotBeHeld.isHeldByCurrentThread()) { + Log.e(TAG, String.format("Lock {%s} should not be held before acquiring lock {%s}" + + " This could lead to a deadlock.", + lockThatShouldNotBeHeld, lockToBeAcquired)); + } + } +} diff --git a/src/com/android/providers/media/photopicker/sync/PickerSyncManager.java b/src/com/android/providers/media/photopicker/sync/PickerSyncManager.java new file mode 100644 index 000000000..b08696741 --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/PickerSyncManager.java @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markAlbumMediaSyncAsComplete; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markSyncAsComplete; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.trackNewAlbumMediaSyncRequests; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.trackNewSyncRequests; + +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.Operation; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.Worker; + +import com.android.modules.utils.BackgroundThread; +import com.android.providers.media.ConfigStore; +import com.android.providers.media.photopicker.PickerSyncController; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * This class manages all the triggers for Picker syncs. + * <p></p> + * There are different use cases for triggering a sync: + * <p> + * 1. Proactive sync - these syncs are proactively performed to minimize the changes that need to be + * synced when the user opens the Photo Picker. The sync should only be performed if the device + * state allows it. + * <p> + * 2. Reactive sync - these syncs are triggered by the user opening the Photo Picker. These should + * be run immediately since the user is likely to be waiting for the sync response on the UI. + */ +public class PickerSyncManager { + private static final String TAG = "SyncWorkManager"; + public static final int SYNC_LOCAL_ONLY = 1; + public static final int SYNC_CLOUD_ONLY = 2; + public static final int SYNC_LOCAL_AND_CLOUD = 3; + + @IntDef(value = { SYNC_LOCAL_ONLY, SYNC_CLOUD_ONLY, SYNC_LOCAL_AND_CLOUD }) + @Retention(RetentionPolicy.SOURCE) + public @interface SyncSource {} + + public static final int SYNC_RESET_MEDIA = 1; + public static final int SYNC_RESET_ALBUM = 2; + + @IntDef(value = {SYNC_RESET_MEDIA, SYNC_RESET_ALBUM}) + @Retention(RetentionPolicy.SOURCE) + public @interface SyncResetType {} + + static final String SYNC_WORKER_INPUT_AUTHORITY = "INPUT_AUTHORITY"; + static final String SYNC_WORKER_INPUT_SYNC_SOURCE = "INPUT_SYNC_TYPE"; + static final String SYNC_WORKER_INPUT_RESET_TYPE = "INPUT_RESET_TYPE"; + static final String SYNC_WORKER_INPUT_ALBUM_ID = "INPUT_ALBUM_ID"; + static final String SYNC_WORKER_TAG_IS_PERIODIC = "PERIODIC"; + private static final int SYNC_MEDIA_PERIODIC_WORK_INTERVAL = 4; // Time unit is hours. + private static final int RESET_ALBUM_MEDIA_PERIODIC_WORK_INTERVAL = 12; // Time unit is hours. + + private static final String PERIODIC_SYNC_WORK_NAME; + private static final String PROACTIVE_LOCAL_SYNC_WORK_NAME; + private static final String PROACTIVE_SYNC_WORK_NAME; + public static final String IMMEDIATE_LOCAL_SYNC_WORK_NAME; + private static final String IMMEDIATE_CLOUD_SYNC_WORK_NAME; + public static final String IMMEDIATE_ALBUM_SYNC_WORK_NAME; + private static final String PERIODIC_ALBUM_RESET_WORK_NAME; + + static { + final String syncPeriodicPrefix = "SYNC_MEDIA_PERIODIC_"; + final String syncProactivePrefix = "SYNC_MEDIA_PROACTIVE_"; + final String syncImmediatePrefix = "SYNC_MEDIA_IMMEDIATE_"; + final String syncAllSuffix = "ALL"; + final String syncLocalSuffix = "LOCAL"; + final String syncCloudSuffix = "CLOUD"; + + PERIODIC_ALBUM_RESET_WORK_NAME = "RESET_ALBUM_MEDIA_PERIODIC"; + PERIODIC_SYNC_WORK_NAME = syncPeriodicPrefix + syncAllSuffix; + PROACTIVE_LOCAL_SYNC_WORK_NAME = syncProactivePrefix + syncLocalSuffix; + PROACTIVE_SYNC_WORK_NAME = syncProactivePrefix + syncAllSuffix; + IMMEDIATE_LOCAL_SYNC_WORK_NAME = syncImmediatePrefix + syncLocalSuffix; + IMMEDIATE_CLOUD_SYNC_WORK_NAME = syncImmediatePrefix + syncCloudSuffix; + IMMEDIATE_ALBUM_SYNC_WORK_NAME = "SYNC_ALBUM_MEDIA_IMMEDIATE"; + } + + private final WorkManager mWorkManager; + private final ConfigStore mConfigStore; + private final Context mContext; + + public PickerSyncManager(@NonNull WorkManager workManager, + @NonNull Context context, + @NonNull ConfigStore configStore, + boolean shouldSchedulePeriodicSyncs) { + mWorkManager = requireNonNull(workManager); + mConfigStore = requireNonNull(configStore); + mContext = requireNonNull(context); + + if (shouldSchedulePeriodicSyncs) { + setUpPeriodicWork(); + } + + // Subscribe to device config changes so we can enable periodic workers if Cloud + // Photopicker is enabled. + mConfigStore.addOnChangeListener(BackgroundThread.getExecutor(), this::setUpPeriodicWork); + } + + /** + * Will register new unique {@link Worker} for periodic sync and picker database maintenance if + * the cloud photopicker experiment is currently enabled. + */ + private void setUpPeriodicWork() { + + if (mConfigStore.isCloudMediaInPhotoPickerEnabled()) { + PickerSyncNotificationHelper.createNotificationChannel(mContext); + + schedulePeriodicSyncs(); + schedulePeriodicAlbumReset(); + } else { + // Disable any scheduled ongoing work if the feature is disabled. + mWorkManager.cancelUniqueWork(PERIODIC_SYNC_WORK_NAME); + mWorkManager.cancelUniqueWork(PERIODIC_ALBUM_RESET_WORK_NAME); + } + } + + /** + * Returns true if the given unique work is pending. In case the unique work is complete or + * there was an error in getting the work state, it returns false. + */ + public boolean isUniqueWorkPending(String uniqueWorkName) { + ListenableFuture<List<WorkInfo>> future = + mWorkManager.getWorkInfosForUniqueWork(uniqueWorkName); + try { + List<WorkInfo> workInfos = future.get(); + for (WorkInfo workInfo : workInfos) { + if (!workInfo.getState().isFinished()) { + return true; + } + } + return false; + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Error occurred in fetching work info - ignore pending work"); + return false; + } + } + + private void schedulePeriodicSyncs() { + Log.i(TAG, "Scheduling periodic proactive syncs"); + + final Data inputData = + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_AND_CLOUD)); + final PeriodicWorkRequest periodicSyncRequest = getPeriodicProactiveSyncRequest(inputData); + + try { + // Note that the first execution of periodic work happens immediately or as soon as the + // given Constraints are met. + final Operation enqueueOperation = mWorkManager + .enqueueUniquePeriodicWork( + PERIODIC_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicSyncRequest + ); + + // Check that the request has been successfully enqueued. + enqueueOperation.getResult().get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Could not enqueue periodic proactive picker sync request", e); + } + } + + private void schedulePeriodicAlbumReset() { + Log.i(TAG, "Scheduling periodic picker album data resets"); + + final Data inputData = + new Data( + Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, + SYNC_LOCAL_AND_CLOUD, + SYNC_WORKER_INPUT_RESET_TYPE, + SYNC_RESET_ALBUM)); + final PeriodicWorkRequest periodicAlbumResetRequest = + getPeriodicAlbumResetRequest(inputData); + + try { + // Note that the first execution of periodic work happens immediately or as soon + // as the given Constraints are met. + Operation enqueueOperation = + mWorkManager.enqueueUniquePeriodicWork( + PERIODIC_ALBUM_RESET_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicAlbumResetRequest); + + // Check that the request has been successfully enqueued. + enqueueOperation.getResult().get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Could not enqueue periodic picker album resets request", e); + } + } + + /** + * Use this method for proactive syncs. The sync might take a while to start. Some device state + * conditions may apply before the sync can start like battery level etc. + * + * @param localOnly - whether the proactive sync should only sync with the local provider. + */ + public void syncMediaProactively(Boolean localOnly) { + + final int syncSource = localOnly ? SYNC_LOCAL_ONLY : SYNC_LOCAL_AND_CLOUD; + final String workName = + localOnly ? PROACTIVE_LOCAL_SYNC_WORK_NAME : PROACTIVE_SYNC_WORK_NAME; + + final Data inputData = new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, syncSource)); + final OneTimeWorkRequest syncRequest = getOneTimeProactiveSyncRequest(inputData); + + // Don't wait for the sync operation to enqueue so that Picker sync enqueue + // requests in + // order to avoid adding latency to critical MP code paths. + + mWorkManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, syncRequest); + } + + /** + * Use this method for reactive syncs which are user triggered. + * + * @param shouldSyncLocalOnlyData if true indicates that the sync should only be triggered with + * the local provider. Otherwise, sync will be triggered for both + * local and cloud provider. + */ + public void syncMediaImmediately(boolean shouldSyncLocalOnlyData) { + syncMediaImmediately(PickerSyncManager.SYNC_LOCAL_ONLY, IMMEDIATE_LOCAL_SYNC_WORK_NAME); + if (!shouldSyncLocalOnlyData) { + syncMediaImmediately(PickerSyncManager.SYNC_CLOUD_ONLY, IMMEDIATE_CLOUD_SYNC_WORK_NAME); + } + } + + /** + * Use this method for reactive syncs with either, local and cloud providers, or both. + */ + private void syncMediaImmediately(@SyncSource int syncSource, @NonNull String workName) { + final Data inputData = new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, syncSource)); + final OneTimeWorkRequest syncRequest = + buildOneTimeWorkerRequest(ImmediateSyncWorker.class, inputData); + + // Track the new sync request(s) + trackNewSyncRequests(syncSource, syncRequest.getId()); + + // Enqueue local or cloud sync request + try { + final Operation enqueueOperation = mWorkManager + .enqueueUniqueWork(workName, ExistingWorkPolicy.APPEND_OR_REPLACE, syncRequest); + + // Check that the request has been successfully enqueued. + enqueueOperation.getResult().get(); + } catch (Exception e) { + Log.e(TAG, "Could not enqueue expedited picker sync request", e); + markSyncAsComplete(syncSource, syncRequest.getId()); + } + } + + /** + * Use this method for reactive syncs which are user action triggered. + * + * @param albumId is the id of the album that needs to be synced. + * @param authority The authority of the album media. + */ + public void syncAlbumMediaForProviderImmediately( + @NonNull String albumId, @NonNull String authority) { + boolean isLocal = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY.equals(authority); + syncAlbumMediaForProviderImmediately(albumId, getSyncSource(isLocal), authority); + } + + /** + * Use this method for reactive syncs which are user action triggered. + * + * @param albumId is the id of the album that needs to be synced. + * @param syncSource indicates if the sync is required with local provider or cloud provider or + * both. + */ + private void syncAlbumMediaForProviderImmediately( + @NonNull String albumId, @SyncSource int syncSource, String authority) { + final Data inputData = + new Data( + Map.of( + SYNC_WORKER_INPUT_AUTHORITY, authority, + SYNC_WORKER_INPUT_SYNC_SOURCE, syncSource, + SYNC_WORKER_INPUT_RESET_TYPE, SYNC_RESET_ALBUM, + SYNC_WORKER_INPUT_ALBUM_ID, albumId)); + final OneTimeWorkRequest resetRequest = + buildOneTimeWorkerRequest(MediaResetWorker.class, inputData); + final OneTimeWorkRequest syncRequest = + buildOneTimeWorkerRequest(ImmediateAlbumSyncWorker.class, inputData); + + // Track the new sync request(s) + trackNewAlbumMediaSyncRequests(syncSource, resetRequest.getId()); + trackNewAlbumMediaSyncRequests(syncSource, syncRequest.getId()); + + // Enqueue local or cloud sync requests + try { + final Operation enqueueOperation = + mWorkManager + .beginUniqueWork( + IMMEDIATE_ALBUM_SYNC_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + resetRequest) + .then(syncRequest).enqueue(); + + // Check that the request has been successfully enqueued. + enqueueOperation.getResult().get(); + } catch (Exception e) { + Log.e(TAG, "Could not enqueue expedited picker sync request", e); + markAlbumMediaSyncAsComplete(syncSource, resetRequest.getId()); + markAlbumMediaSyncAsComplete(syncSource, syncRequest.getId()); + } + } + + @NotNull + private OneTimeWorkRequest buildOneTimeWorkerRequest( + @NotNull Class<? extends Worker> workerClass, @NonNull Data inputData) { + return new OneTimeWorkRequest.Builder(workerClass) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + } + + @NotNull + private PeriodicWorkRequest getPeriodicProactiveSyncRequest(@NotNull Data inputData) { + return new PeriodicWorkRequest.Builder( + ProactiveSyncWorker.class, SYNC_MEDIA_PERIODIC_WORK_INTERVAL, TimeUnit.HOURS) + .setInputData(inputData) + .setConstraints(getRequiresChargingAndIdleConstraints()) + .build(); + } + + @NotNull + private PeriodicWorkRequest getPeriodicAlbumResetRequest(@NotNull Data inputData) { + + return new PeriodicWorkRequest.Builder( + MediaResetWorker.class, + RESET_ALBUM_MEDIA_PERIODIC_WORK_INTERVAL, + TimeUnit.HOURS) + .setInputData(inputData) + .setConstraints(getRequiresChargingAndIdleConstraints()) + .addTag(SYNC_WORKER_TAG_IS_PERIODIC) + .build(); + } + + @NotNull + private OneTimeWorkRequest getOneTimeProactiveSyncRequest(@NotNull Data inputData) { + Constraints constraints = new Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build(); + + return new OneTimeWorkRequest.Builder(ProactiveSyncWorker.class) + .setInputData(inputData) + .setConstraints(constraints) + .build(); + } + + @NotNull + private static Constraints getRequiresChargingAndIdleConstraints() { + return new Constraints.Builder() + .setRequiresCharging(true) + .setRequiresDeviceIdle(true) + .build(); + } + + @SyncSource + private static int getSyncSource(boolean isLocal) { + return isLocal + ? SYNC_LOCAL_ONLY + : SYNC_CLOUD_ONLY; + } +} diff --git a/src/com/android/providers/media/photopicker/sync/PickerSyncNotificationHelper.java b/src/com/android/providers/media/photopicker/sync/PickerSyncNotificationHelper.java new file mode 100644 index 000000000..9579ccf5d --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/PickerSyncNotificationHelper.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2023 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.sync; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.res.Resources; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.core.app.NotificationCompat; +import androidx.work.ForegroundInfo; + +import com.android.modules.utils.build.SdkLevel; +import com.android.providers.media.R; + +/** + * Helper functions for Picker sync notifications. + */ +public class PickerSyncNotificationHelper { + private static final String TAG = "SyncNotifHelper"; + @VisibleForTesting + static final String NOTIFICATION_CHANNEL_ID = "PhotoPickerSyncChannel"; + @VisibleForTesting + static final int NOTIFICATION_ID = 0; + private static final int NOTIFICATION_TIMEOUT_MILLIS = 1000; + + + /** + * Created notification channel for Picker Sync notifications. + * Recreating an existing notification channel with its original values performs no operation, + * so it's safe to call this code when starting an app. + */ + public static void createNotificationChannel(@NonNull Context context) { + final String contentTitle = context.getResources() + .getString(R.string.picker_sync_notification_channel); + + final NotificationChannel channel = new NotificationChannel( + NOTIFICATION_CHANNEL_ID, contentTitle, NotificationManager.IMPORTANCE_LOW); + channel.enableLights(false); + channel.enableVibration(false); + + final NotificationManager notificationManager = + context.getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + + /** + * Return Foreground info. This object contains a Notification and notification id that should + * be displayed in the context of a foreground service. + * This method should not be invoked by WorkManager in Android S+ devices. + */ + @NonNull + public static ForegroundInfo getForegroundInfo(@NonNull Context context) { + if (SdkLevel.isAtLeastS()) { + Log.w(TAG, "Picker Sync notifications should not be displayed in S+ devices."); + } + return new ForegroundInfo(NOTIFICATION_ID, getNotification(context)); + } + + /** + * Create a notification to display when Picker sync is happening. + */ + private static Notification getNotification(@NonNull Context context) { + final Resources resources = context.getResources(); + final String contentTitle = resources.getString(R.string.picker_sync_notification_title); + final String contentText = resources.getString(R.string.picker_sync_notification_text); + + return new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.picker_app_icon) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setSilent(true) + .setTimeoutAfter(NOTIFICATION_TIMEOUT_MILLIS) + .build(); + } +} diff --git a/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorker.java b/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorker.java new file mode 100644 index 000000000..c93d74f8f --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorker.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.getCloudSyncTracker; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.getLocalSyncTracker; +import static com.android.providers.media.photopicker.sync.SyncTrackerRegistry.markSyncAsComplete; + +import android.content.Context; +import android.os.CancellationSignal; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.work.ForegroundInfo; +import androidx.work.ListenableWorker; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException; + +/** + * This is a {@link Worker} class responsible for proactively syncing media with the correct sync + * source. + */ +public class ProactiveSyncWorker extends Worker { + private static final String TAG = "PSyncWorker"; + private final Context mContext; + private final CancellationSignal mCancellationSignal = new CancellationSignal(); + + /** + * Creates an instance of the {@link Worker}. + * + * @param context the application {@link Context} + * @param workerParams the set of {@link WorkerParameters} + */ + public ProactiveSyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + mContext = context; + } + + @NonNull + @Override + public ListenableWorker.Result doWork() { + // Do not allow endless re-runs of this worker, if this isn't the original run, + // just succeed and wait until the next scheduled run. + if (getRunAttemptCount() > 0) { + Log.w(TAG, "Worker retry was detected, ending this run in failure."); + return ListenableWorker.Result.failure(); + } + final int syncSource = + getInputData() + .getInt( + SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ + SYNC_LOCAL_AND_CLOUD); + + Log.i( + TAG, + String.format("Starting proactive picker sync from sync source: %s", syncSource)); + + try { + if (syncSource == SYNC_LOCAL_AND_CLOUD || syncSource == SYNC_LOCAL_ONLY) { + // Instantiate sync state tracker. + final SyncTracker localSyncTracker = getLocalSyncTracker(); + localSyncTracker.createSyncFuture(getId()); + + // Complete sync and mark work tracker as finished. + checkIsWorkerStopped(); + PickerSyncController.getInstanceOrThrow() + .syncAllMediaFromLocalProvider(mCancellationSignal); + localSyncTracker.markSyncCompleted(getId()); + Log.i(TAG, "Completed picker proactive sync complete from local provider."); + } + if (syncSource == SYNC_LOCAL_AND_CLOUD || syncSource == SYNC_CLOUD_ONLY) { + // Instantiate sync state tracker. + final SyncTracker cloudSyncTracker = getCloudSyncTracker(); + cloudSyncTracker.createSyncFuture(getId()); + + // Complete sync and mark work tracker as finished. + checkIsWorkerStopped(); + PickerSyncController.getInstanceOrThrow() + .syncAllMediaFromCloudProvider(mCancellationSignal); + cloudSyncTracker.markSyncCompleted(getId()); + Log.i(TAG, "Completed picker proactive sync complete from cloud provider."); + } + return ListenableWorker.Result.success(); + } catch (IllegalStateException | RequestObsoleteException e) { + Log.e(TAG, "Could not complete proactive sync for sync source: " + syncSource, e); + + // Mark all pending syncs as finished and set failure result. + markSyncAsComplete(syncSource, getId()); + return ListenableWorker.Result.failure(); + } + } + + private void checkIsWorkerStopped() throws RequestObsoleteException { + if (isStopped()) { + throw new RequestObsoleteException("Work is stopped " + getId()); + } + } + + @Override + @NonNull + public ForegroundInfo getForegroundInfo() { + Log.e(TAG, "Proactive Sync Worker should not run as an expedited task"); + return PickerSyncNotificationHelper.getForegroundInfo(mContext); + } + + @Override + public void onStopped() { + Log.w(TAG, "Worker is stopped. Clearing all pending futures. It's possible that the sync " + + "still finishes running if it has started already."); + // Send CancellationSignal to any running tasks. + mCancellationSignal.cancel(); + final int syncSource = getInputData() + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, /* defaultValue */ SYNC_LOCAL_AND_CLOUD); + markSyncAsComplete(syncSource, getId()); + } + + @VisibleForTesting + CancellationSignal getCancellationSignal() { + return mCancellationSignal; + } +} diff --git a/src/com/android/providers/media/photopicker/sync/SyncTracker.java b/src/com/android/providers/media/photopicker/sync/SyncTracker.java new file mode 100644 index 000000000..33c47707f --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/SyncTracker.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2023 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.sync; + +import android.util.Log; + +import androidx.annotation.VisibleForTesting; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * This class tracks all pending syncs in a synchronized map. + */ +public class SyncTracker { + private static final String TAG = "PickerSyncTracker"; + private static final long SYNC_FUTURE_TIMEOUT = 20; // Minutes + private static final Object FUTURE_RESULT = new Object(); // Placeholder result object + private final Map<UUID, CompletableFuture<Object>> mFutureMap = + Collections.synchronizedMap(new HashMap<>()); + + /** + * Use this method to create a picker sync future and track its progress. This should be + * called either when a new sync request is enqueued, or when a new sync request starts + * processing. + * @param workRequestID the work request id of a picker sync. + */ + public void createSyncFuture(UUID workRequestID) { + createSyncFuture(workRequestID, SYNC_FUTURE_TIMEOUT, TimeUnit.MINUTES); + } + + /** + * Use this method to create a picker sync future with a custom timeout. This method is + * intended to be used from tests. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public void createSyncFuture(UUID workRequestID, long syncFutureTimeout, TimeUnit timeUnit) { + // Create a CompletableFuture that tracks a sync operation. The future will + // automatically be marked as finished after a given timeout. This is important because + // we're not able to track all WorkManager failures. In case of a failure to run the + // sync, we'll need to ensure that the future expires automatically after a given + // timeout. + final CompletableFuture<Object> syncFuture = new CompletableFuture<>(); + syncFuture.completeOnTimeout(FUTURE_RESULT, syncFutureTimeout, timeUnit); + mFutureMap.put(workRequestID, syncFuture); + Log.i(TAG, String.format("Created new sync future %s. Future map: %s", + syncFuture, mFutureMap)); + } + + /** + * Use this method to mark a picker sync future as complete. If this is not invoked within a + * configured time limit, the future will automatically be set as done. + * @param workRequestID the work request id of a picker sync. + */ + public void markSyncCompleted(UUID workRequestID) { + if (mFutureMap.containsKey(workRequestID)) { + mFutureMap.get(workRequestID).complete(FUTURE_RESULT); + mFutureMap.remove(workRequestID); + Log.i(TAG, String.format( + "Marked sync future complete for work id: %s. Future map: %s", + workRequestID, mFutureMap)); + } else { + Log.w(TAG, String.format("Attempted to complete sync future that is not currently " + + "tracked for work id: %s. Future map: %s", + workRequestID, mFutureMap)); + } + } + + /** + * Use this method to check if any sync request is still pending. + * @return a {@link Collection} of {@link CompletableFuture} of pending syncs. This can be + * used to track when all pending are complete. + */ + public Collection<CompletableFuture<Object>> pendingSyncFutures() { + flushAllCompleteFutures(); + Log.i(TAG, String.format("Returning pending sync future map: %s", mFutureMap)); + return mFutureMap.values(); + } + + private void flushAllCompleteFutures() { + // The synchronized map only guarantees serial access if all access to the backing map + // is accomplished through the returned map. Since the removeIf() method uses iterators to + // access the underlying map, it should be in a synchronized block. + Log.d(TAG, String.format("Flushing all complete futures: %s", mFutureMap)); + synchronized (mFutureMap) { + mFutureMap.values().removeIf(CompletableFuture::isDone); + } + } +} diff --git a/src/com/android/providers/media/photopicker/sync/SyncTrackerRegistry.java b/src/com/android/providers/media/photopicker/sync/SyncTrackerRegistry.java new file mode 100644 index 000000000..5a5f6c90f --- /dev/null +++ b/src/com/android/providers/media/photopicker/sync/SyncTrackerRegistry.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.util.UUID; + +/** + * This class stores all sync trackers. + */ +public class SyncTrackerRegistry { + private static SyncTracker sLocalSyncTracker = new SyncTracker(); + private static SyncTracker sLocalAlbumSyncTracker = new SyncTracker(); + private static SyncTracker sCloudSyncTracker = new SyncTracker(); + private static SyncTracker sCloudAlbumSyncTracker = new SyncTracker(); + + public static SyncTracker getLocalSyncTracker() { + return sLocalSyncTracker; + } + + /** + * This setter is required to inject mock data for tests. Do not use this anywhere else. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setLocalSyncTracker(SyncTracker syncTracker) { + sLocalSyncTracker = syncTracker; + } + + public static SyncTracker getLocalAlbumSyncTracker() { + return sLocalAlbumSyncTracker; + } + + /** + * This setter is required to inject mock data for tests. Do not use this anywhere else. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setLocalAlbumSyncTracker( + SyncTracker localAlbumSyncTracker) { + sLocalAlbumSyncTracker = localAlbumSyncTracker; + } + + public static SyncTracker getCloudSyncTracker() { + return sCloudSyncTracker; + } + + /** + * This setter is required to inject mock data for tests. Do not use this anywhere else. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setCloudSyncTracker( + SyncTracker cloudSyncTracker) { + sCloudSyncTracker = cloudSyncTracker; + } + + public static SyncTracker getCloudAlbumSyncTracker() { + return sCloudAlbumSyncTracker; + } + + /** + * This setter is required to inject mock data for tests. Do not use this anywhere else. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setCloudAlbumSyncTracker( + SyncTracker cloudAlbumSyncTracker) { + sCloudAlbumSyncTracker = cloudAlbumSyncTracker; + } + + /** + * Return the appropriate sync tracker. + * @param isLocal is true when sync with local provider needs to be tracked. It is false for + * sync with cloud provider. + * @return the appropriate {@link SyncTracker} object. + */ + public static SyncTracker getSyncTracker(boolean isLocal) { + if (isLocal) { + return sLocalSyncTracker; + } else { + return sCloudSyncTracker; + } + } + + /** + * Return the appropriate album sync tracker. + * @param isLocal is true when sync with local provider needs to be tracked. It is false for + * sync with cloud provider. + * @return the appropriate {@link SyncTracker} object. + */ + public static SyncTracker getAlbumSyncTracker(boolean isLocal) { + if (isLocal) { + return sLocalAlbumSyncTracker; + } else { + return sCloudAlbumSyncTracker; + } + } + + /** + * Create the required completable futures for new media sync requests that need to be tracked. + */ + public static void trackNewSyncRequests( + @PickerSyncManager.SyncSource int syncSource, + @NonNull UUID syncRequestId) { + if (syncSource == SYNC_LOCAL_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getLocalSyncTracker().createSyncFuture(syncRequestId); + } + if (syncSource == SYNC_CLOUD_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getCloudSyncTracker().createSyncFuture(syncRequestId); + } + } + + /** + * Create the required completable futures for new album media sync requests that need to be + * tracked. + */ + public static void trackNewAlbumMediaSyncRequests( + @PickerSyncManager.SyncSource int syncSource, + @NonNull UUID syncRequestId) { + if (syncSource == SYNC_LOCAL_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getLocalAlbumSyncTracker().createSyncFuture(syncRequestId); + } + if (syncSource == SYNC_CLOUD_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getCloudAlbumSyncTracker().createSyncFuture(syncRequestId); + } + } + + /** + * Mark the required futures as complete for existing media sync requests. + */ + public static void markSyncAsComplete( + @PickerSyncManager.SyncSource int syncSource, + @NonNull UUID syncRequestId) { + if (syncSource == SYNC_LOCAL_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getLocalSyncTracker().markSyncCompleted(syncRequestId); + } + if (syncSource == SYNC_CLOUD_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getCloudSyncTracker().markSyncCompleted(syncRequestId); + } + } + + /** + * Mark the required futures as complete for existing album media sync requests. + */ + public static void markAlbumMediaSyncAsComplete( + @PickerSyncManager.SyncSource int syncSource, + @NonNull UUID syncRequestId) { + if (syncSource == SYNC_LOCAL_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getLocalAlbumSyncTracker().markSyncCompleted(syncRequestId); + } + if (syncSource == SYNC_CLOUD_ONLY || syncSource == SYNC_LOCAL_AND_CLOUD) { + getCloudAlbumSyncTracker().markSyncCompleted(syncRequestId); + } + } +} diff --git a/src/com/android/providers/media/photopicker/ui/AlbumGridHolder.java b/src/com/android/providers/media/photopicker/ui/AlbumGridHolder.java index 657ecc8de..a90f6388b 100644 --- a/src/com/android/providers/media/photopicker/ui/AlbumGridHolder.java +++ b/src/com/android/providers/media/photopicker/ui/AlbumGridHolder.java @@ -16,6 +16,7 @@ package com.android.providers.media.photopicker.ui; +import android.provider.CloudMediaProviderContract; import android.text.TextUtils; import android.view.View; import android.widget.ImageView; @@ -38,6 +39,7 @@ class AlbumGridHolder extends RecyclerView.ViewHolder { private final ImageLoader mImageLoader; private final ImageView mIconThumb; + private final ImageView mIconDefaultThumb; private final TextView mAlbumName; private final TextView mItemCount; private final boolean mHasMimeTypeFilter; @@ -50,6 +52,7 @@ class AlbumGridHolder extends RecyclerView.ViewHolder { super(itemView); mIconThumb = itemView.findViewById(R.id.icon_thumbnail); + mIconDefaultThumb = itemView.findViewById(R.id.icon_default_thumbnail); mAlbumName = itemView.findViewById(R.id.album_name); mItemCount = itemView.findViewById(R.id.item_count); mImageLoader = imageLoader; @@ -58,23 +61,38 @@ class AlbumGridHolder extends RecyclerView.ViewHolder { } void bind(@NonNull Category category) { - itemView.setOnClickListener(v -> mOnAlbumClickListener.onAlbumClick(category)); - mImageLoader.loadAlbumThumbnail(category, mIconThumb); - mAlbumName.setText(category.getDisplayName(itemView.getContext())); + int position = getAbsoluteAdapterPosition(); + itemView.setOnClickListener(v -> mOnAlbumClickListener.onAlbumClick(category, position)); + // Show default thumbnail icons if merged album is empty. + int defaultResId = -1; + if (CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES.equals(category.getId())) { + defaultResId = R.drawable.thumbnail_favorites; + } else if (CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS + .equals(category.getId())) { + defaultResId = R.drawable.thumbnail_videos; + } + mImageLoader.loadAlbumThumbnail(category, mIconThumb, defaultResId, mIconDefaultThumb); + mAlbumName.setText(category.getDisplayName(itemView.getContext())); // Check whether there is a mime type filter or not. If yes, hide the item count. Otherwise, // show the item count and update the count. - if (mHasMimeTypeFilter) { - mItemCount.setVisibility(View.GONE); - } else { - mItemCount.setVisibility(View.VISIBLE); - final int itemCount = category.getItemCount(); - final String quantityText = - StringUtils.getICUFormatString( - itemView.getResources(), itemCount, R.string.picker_album_item_count); - final String itemCountString = NumberFormat.getInstance(Locale.getDefault()).format( - itemCount); - mItemCount.setText(TextUtils.expandTemplate(quantityText, itemCountString)); + if (mItemCount.getVisibility() == View.VISIBLE) { + // As per the current requirements, we are hiding album's item count and this piece of + // code will never execute. for now keeping it here as it is, in case if in future we + // need to show album's item count. + if (mHasMimeTypeFilter) { + mItemCount.setVisibility(View.GONE); + } else { + mItemCount.setVisibility(View.VISIBLE); + final int itemCount = category.getItemCount(); + final String quantityText = + StringUtils.getICUFormatString( + itemView.getResources(), itemCount, + R.string.picker_album_item_count); + final String itemCountString = NumberFormat.getInstance(Locale.getDefault()).format( + itemCount); + mItemCount.setText(TextUtils.expandTemplate(quantityText, itemCountString)); + } } } } diff --git a/src/com/android/providers/media/photopicker/ui/AlbumsTabAdapter.java b/src/com/android/providers/media/photopicker/ui/AlbumsTabAdapter.java index 8cae25956..7b2ed05a2 100644 --- a/src/com/android/providers/media/photopicker/ui/AlbumsTabAdapter.java +++ b/src/com/android/providers/media/photopicker/ui/AlbumsTabAdapter.java @@ -16,6 +16,8 @@ package com.android.providers.media.photopicker.ui; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; + import android.view.View; import android.view.ViewGroup; @@ -80,10 +82,10 @@ class AlbumsTabAdapter extends TabAdapter { } void updateCategoryList(@NonNull List<Category> categoryList) { - setAllItems(categoryList); + setAllItems(categoryList, /* reset */ ACTION_VIEW_CREATED); } interface OnAlbumClickListener { - void onAlbumClick(@NonNull Category category); + void onAlbumClick(@NonNull Category category, int position); } } diff --git a/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java b/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java index de8bcb83e..3781e26e1 100644 --- a/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java +++ b/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java @@ -17,6 +17,7 @@ package com.android.providers.media.photopicker.ui; import android.content.Context; import android.os.Bundle; +import android.util.Log; import android.view.View; import androidx.annotation.NonNull; @@ -27,18 +28,20 @@ import androidx.fragment.app.FragmentTransaction; import com.android.providers.media.R; import com.android.providers.media.photopicker.util.LayoutModeUtils; +import java.util.ArrayList; + /** * Albums tab fragment for showing the albums */ public class AlbumsTabFragment extends TabFragment { - + private static final String TAG = PhotosTabFragment.class.getSimpleName(); private static final int MINIMUM_SPAN_COUNT = 2; private static final int GRID_COLUMN_COUNT = 2; @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - final Context context = getContext(); + final Context context = requireContext(); // Set the pane title for A11y. view.setAccessibilityPaneTitle(getString(R.string.picker_albums)); @@ -56,9 +59,14 @@ public class AlbumsTabFragment extends TabFragment { mOnChooseAppBannerEventListener, mOnCloudMediaAvailableBannerEventListener, mOnAccountUpdatedBannerEventListener, mOnChooseAccountBannerEventListener); mPickerViewModel.getCategories().observe(this, categoryList -> { - adapter.updateCategoryList(categoryList); - // Handle emptyView's visibility - updateVisibilityForEmptyView(/* shouldShowEmptyView */ categoryList.size() == 0); + if (categoryList.size() == 1 && categoryList.get(0).getId().equals("EMPTY_VIEW")) { + adapter.updateCategoryList(new ArrayList<>()); + updateVisibilityForEmptyView(false); + } else { + adapter.updateCategoryList(categoryList); + // Handle emptyView's visibility + updateVisibilityForEmptyView(/* shouldShowEmptyView */ categoryList.size() == 0); + } }); final AlbumsTabItemDecoration itemDecoration = new AlbumsTabItemDecoration(context); @@ -68,7 +76,7 @@ public class AlbumsTabFragment extends TabFragment { mRecyclerView.setColumnWidth(albumSize + spacing); mRecyclerView.setMinimumSpanCount(MINIMUM_SPAN_COUNT); - setLayoutManager(adapter, GRID_COLUMN_COUNT); + setLayoutManager(context, adapter, GRID_COLUMN_COUNT); mRecyclerView.setAdapter(adapter); mRecyclerView.addItemDecoration(itemDecoration); } @@ -76,11 +84,20 @@ public class AlbumsTabFragment extends TabFragment { @Override public void onResume() { super.onResume(); - getPickerActivity().updateCommonLayouts(LayoutModeUtils.MODE_ALBUMS_TAB, /* title */ ""); + + requirePickerActivity() + .updateCommonLayouts(LayoutModeUtils.MODE_ALBUMS_TAB, /* title */ ""); } - private final AlbumsTabAdapter.OnAlbumClickListener mOnAlbumClickListener = category -> - PhotosTabFragment.show(getActivity().getSupportFragmentManager(), category); + private final AlbumsTabAdapter.OnAlbumClickListener mOnAlbumClickListener = + (category, position) -> { + mPickerViewModel.logAlbumOpened(category, position); + try { + PhotosTabFragment.show(requireActivity().getSupportFragmentManager(), category); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } + }; /** * Create the albums tab fragment and add it into the FragmentManager diff --git a/src/com/android/providers/media/photopicker/ui/ImageLoader.java b/src/com/android/providers/media/photopicker/ui/ImageLoader.java index 0d77b8287..12433fcae 100644 --- a/src/com/android/providers/media/photopicker/ui/ImageLoader.java +++ b/src/com/android/providers/media/photopicker/ui/ImageLoader.java @@ -20,17 +20,16 @@ import static com.bumptech.glide.load.resource.bitmap.Downsampler.PREFERRED_COLO import android.content.Context; import android.graphics.Bitmap; -import android.graphics.ImageDecoder; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.provider.CloudMediaProviderContract; import android.provider.MediaStore; -import android.util.Log; +import android.view.View; import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.providers.media.photopicker.data.glide.GlideLoadable; import com.android.providers.media.photopicker.data.model.Category; import com.android.providers.media.photopicker.data.model.Item; @@ -42,6 +41,8 @@ import com.bumptech.glide.load.resource.gif.GifDrawable; import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.signature.ObjectKey; +import java.util.Optional; + /** * A class to assist with loading and managing the Images (i.e. thumbnails and preview) associated * with item. @@ -55,6 +56,7 @@ public class ImageLoader { RequestOptions.option(THUMBNAIL_REQUEST, /* enableThumbnail */ true); private final Context mContext; private final PreferredColorSpace mPreferredColorSpace; + private static final String PREVIEW_PREFIX = "preview_"; public ImageLoader(Context context) { mContext = context; @@ -71,12 +73,24 @@ public class ImageLoader { * @param category the album * @param imageView the imageView shows the thumbnail */ - public void loadAlbumThumbnail(@NonNull Category category, @NonNull ImageView imageView) { + public void loadAlbumThumbnail(@NonNull Category category, @NonNull ImageView imageView, + int defaultThumbnailRes, @NonNull ImageView defaultIcon) { // Always show all thumbnails as bitmap images instead of drawables // This is to ensure that we do not animate any thumbnail (for eg GIF) // TODO(b/194285082): Use drawable instead of bitmap, as it saves memory. - loadWithGlide(getBitmapRequestBuilder(category.getCoverUri()), THUMBNAIL_OPTION, - /* signature */ null, imageView); + if (category.getCoverUri() != null || defaultThumbnailRes == -1) { + defaultIcon.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + + loadWithGlide(getBitmapRequestBuilder(category.toGlideLoadable()), THUMBNAIL_OPTION, + /* signature */ null, imageView); + } else { + imageView.setVisibility(View.INVISIBLE); + defaultIcon.setVisibility(View.VISIBLE); + + loadWithGlide(getDrawableRequestBuilder(mContext.getDrawable(defaultThumbnailRes)), + THUMBNAIL_OPTION, /* signature */ null, defaultIcon); + } } /** @@ -86,11 +100,12 @@ public class ImageLoader { * @param imageView the imageView shows the thumbnail */ public void loadPhotoThumbnail(@NonNull Item item, @NonNull ImageView imageView) { + final GlideLoadable loadable = item.toGlideLoadable(); // Always show all thumbnails as bitmap images instead of drawables // This is to ensure that we do not animate any thumbnail (for eg GIF) // TODO(b/194285082): Use drawable instead of bitmap, as it saves memory. - loadWithGlide(getBitmapRequestBuilder(item.getContentUri()), THUMBNAIL_OPTION, - getGlideSignature(item, /* prefix */ ""), imageView); + loadWithGlide(getBitmapRequestBuilder(loadable), THUMBNAIL_OPTION, + getGlideSignature(loadable, /* prefix */ null), imageView); } /** @@ -100,65 +115,63 @@ public class ImageLoader { * @param imageView the imageView shows the image */ public void loadImagePreview(@NonNull Item item, @NonNull ImageView imageView) { + final GlideLoadable loadable = item.toGlideLoadable(); if (item.isGif()) { - loadWithGlide(getGifRequestBuilder(item.getContentUri()), /* requestOptions */ null, - getGlideSignature(item, /* prefix */ ""), imageView); + loadWithGlide( + getGifRequestBuilder(loadable), + /* requestOptions */ null, + getGlideSignature(loadable, /* prefix= */ PREVIEW_PREFIX), + imageView); return; } if (item.isAnimatedWebp()) { - loadAnimatedWebpPreview(item, imageView); + loadWithGlide( + getDrawableRequestBuilder(loadable), + /* requestOptions */ null, + getGlideSignature(loadable, PREVIEW_PREFIX), + imageView); return; } // Preview as bitmap image for all other image types - loadWithGlide(getBitmapRequestBuilder(item.getContentUri()), /* requestOptions */ null, - getGlideSignature(item, /* prefix */ ""), imageView); - } - - private void loadAnimatedWebpPreview(@NonNull Item item, @NonNull ImageView imageView) { - final Uri uri = item.getContentUri(); - final ImageDecoder.Source source = ImageDecoder.createSource(mContext.getContentResolver(), - uri); - Drawable drawable = null; - try { - drawable = ImageDecoder.decodeDrawable(source); - } catch (Exception e) { - Log.d(TAG, "Failed to decode drawable for uri: " + uri, e); - } - - // If we failed to decode drawable for a source using ImageDecoder, then try using uri - // directly. Glide will show static image for an animated webp. That is okay as we tried our - // best to load animated webp but couldn't, and we anyway show the GIF badge in preview. - loadWithGlide(getDrawableRequestBuilder(drawable == null ? uri : drawable), - /* requestOptions */ null, getGlideSignature(item, /* prefix */ ""), imageView); + loadWithGlide( + getBitmapRequestBuilder(loadable), + /* requestOptions */ null, + getGlideSignature(loadable, /* prefix= */ PREVIEW_PREFIX), + imageView); } /** * Loads the image from first frame of the given video item */ public void loadImageFromVideoForPreview(@NonNull Item item, @NonNull ImageView imageView) { - loadWithGlide(getBitmapRequestBuilder(item.getContentUri()), - new RequestOptions().frame(1000), getGlideSignature(item, "Preview"), imageView); + final GlideLoadable loadable = item.toGlideLoadable(); + loadWithGlide( + getBitmapRequestBuilder(loadable), + new RequestOptions().frame(1000), + getGlideSignature(loadable, /* prefix= */ PREVIEW_PREFIX), + imageView); } - private ObjectKey getGlideSignature(Item item, String prefix) { - // TODO(b/224725723): Remove media store version from key once MP ids are stable. - return new ObjectKey( - MediaStore.getVersion(mContext) + prefix + item.getContentUri().toString() + - item.getGenerationModified()); + private ObjectKey getGlideSignature(GlideLoadable loadable, @Nullable String prefix) { + // TODO(b/224725723): Remove media store version from key once MP ids are + // stable. + return loadable.getLoadableSignature( + /* prefix= */ MediaStore.getVersion(mContext) + + Optional.ofNullable(prefix).orElse("")); } - private RequestBuilder<Bitmap> getBitmapRequestBuilder(Uri uri) { + private RequestBuilder<Bitmap> getBitmapRequestBuilder(GlideLoadable loadable) { return Glide.with(mContext) .asBitmap() - .load(uri); + .load(loadable); } - private RequestBuilder<GifDrawable> getGifRequestBuilder(Uri uri) { + private RequestBuilder<GifDrawable> getGifRequestBuilder(GlideLoadable loadable) { return Glide.with(mContext) .asGif() - .load(uri); + .load(loadable); } private RequestBuilder<Drawable> getDrawableRequestBuilder(Object model) { diff --git a/src/com/android/providers/media/photopicker/ui/ItemsAction.java b/src/com/android/providers/media/photopicker/ui/ItemsAction.java new file mode 100644 index 000000000..04c6f0473 --- /dev/null +++ b/src/com/android/providers/media/photopicker/ui/ItemsAction.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 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.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Represents the actions that can be performed on lis of items / category items, based on different + * scenarios like next page load, refreshing the list, updating the list on profile switch etc. + */ +public class ItemsAction { + + // This is basically a no-op action which will meet no conditions in the code. + public static final int ACTION_DEFAULT = 0; + public static final int ACTION_VIEW_CREATED = 1; + public static final int ACTION_LOAD_NEXT_PAGE = 2; + public static final int ACTION_CLEAR_AND_UPDATE_LIST = 3; + public static final int ACTION_CLEAR_GRID = 4; + public static final int ACTION_REFRESH_ITEMS = 5; + + + private ItemsAction() { + } + + /** @hide */ + @IntDef({ACTION_DEFAULT, + ACTION_VIEW_CREATED, + ACTION_LOAD_NEXT_PAGE, + ACTION_CLEAR_AND_UPDATE_LIST, + ACTION_CLEAR_GRID, + ACTION_REFRESH_ITEMS}) + @Retention(RetentionPolicy.SOURCE) + public @interface Type { + } +} diff --git a/src/com/android/providers/media/photopicker/ui/MediaItemGridViewHolder.java b/src/com/android/providers/media/photopicker/ui/MediaItemGridViewHolder.java index f149955b6..fef4ad3a7 100644 --- a/src/com/android/providers/media/photopicker/ui/MediaItemGridViewHolder.java +++ b/src/com/android/providers/media/photopicker/ui/MediaItemGridViewHolder.java @@ -25,6 +25,8 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; import androidx.recyclerview.widget.RecyclerView; import com.android.providers.media.R; @@ -35,6 +37,7 @@ import com.android.providers.media.photopicker.data.model.Item; * a video). */ class MediaItemGridViewHolder extends RecyclerView.ViewHolder { + private final LifecycleOwner mLifecycleOwner; private final ImageLoader mImageLoader; private final ImageView mIconThumb; private final ImageView mIconGif; @@ -43,10 +46,24 @@ class MediaItemGridViewHolder extends RecyclerView.ViewHolder { private final TextView mVideoDuration; private final View mOverlayGradient; private final boolean mCanSelectMultiple; - - MediaItemGridViewHolder(@NonNull View itemView, @NonNull ImageLoader imageLoader, - boolean canSelectMultiple) { + private final boolean mShowOrderedSelectionLabel; + private final TextView mSelectedOrderText; + private LiveData<Integer> mSelectionOrder; + private final ImageView mCheckIcon; + + private final View.OnHoverListener mOnMediaItemHoverListener; + private final PhotosTabAdapter.OnMediaItemClickListener mOnMediaItemClickListener; + + MediaItemGridViewHolder( + @NonNull LifecycleOwner lifecycleOwner, + @NonNull View itemView, + @NonNull ImageLoader imageLoader, + @NonNull PhotosTabAdapter.OnMediaItemClickListener onMediaItemClickListener, + View.OnHoverListener onMediaItemHoverListener, + boolean canSelectMultiple, + boolean isOrderedSelection) { super(itemView); + mLifecycleOwner = lifecycleOwner; mIconThumb = itemView.findViewById(R.id.icon_thumbnail); mIconGif = itemView.findViewById(R.id.icon_gif); mIconMotionPhoto = itemView.findViewById(R.id.icon_motion_photo); @@ -54,12 +71,25 @@ class MediaItemGridViewHolder extends RecyclerView.ViewHolder { mVideoDuration = mVideoBadgeContainer.findViewById(R.id.video_duration); mOverlayGradient = itemView.findViewById(R.id.overlay_gradient); mImageLoader = imageLoader; + mOnMediaItemClickListener = onMediaItemClickListener; mCanSelectMultiple = canSelectMultiple; - - itemView.findViewById(R.id.icon_check).setVisibility(mCanSelectMultiple ? VISIBLE : GONE); + mShowOrderedSelectionLabel = isOrderedSelection; + mOnMediaItemHoverListener = onMediaItemHoverListener; + mSelectedOrderText = itemView.findViewById(R.id.selected_order); + mCheckIcon = itemView.findViewById(R.id.icon_check); + mCheckIcon.setVisibility( + (mCanSelectMultiple && !mShowOrderedSelectionLabel) ? VISIBLE : GONE); + mSelectedOrderText.setVisibility( + (mCanSelectMultiple && mShowOrderedSelectionLabel) ? VISIBLE : GONE); } public void bind(@NonNull Item item, boolean isSelected) { + int position = getAbsoluteAdapterPosition(); + itemView.setOnClickListener(v -> mOnMediaItemClickListener.onItemClick(v, position, this)); + itemView.setOnLongClickListener(v -> + mOnMediaItemClickListener.onItemLongClick(v, position)); + itemView.setOnHoverListener(mOnMediaItemHoverListener); + mImageLoader.loadPhotoThumbnail(item, mIconThumb); mIconGif.setVisibility(item.isGifOrAnimatedWebp() ? VISIBLE : GONE); @@ -83,6 +113,7 @@ class MediaItemGridViewHolder extends RecyclerView.ViewHolder { if (mCanSelectMultiple) { itemView.setSelected(isSelected); + mSelectedOrderText.setText(""); // There is an issue b/223695510 about not selected in Accessibility mode. It only // says selected state, but it doesn't say not selected state. Add the not selected // only to avoid that it says selected twice. @@ -91,15 +122,49 @@ class MediaItemGridViewHolder extends RecyclerView.ViewHolder { } } + /** Sets the LiveData selection order for the current grid item view. */ + public void setSelectionOrder(LiveData<Integer> selectionOrder) { + if (selectionOrder == null) { + mSelectedOrderText.setText(""); + if (mSelectionOrder != null) { + mSelectionOrder.removeObservers(mLifecycleOwner); + } + } else { + mSelectedOrderText.setText(selectionOrder.getValue().toString()); + selectionOrder.observe( + mLifecycleOwner, + val -> { + mSelectedOrderText.setText(val.toString()); + }); + } + mSelectionOrder = selectionOrder; + } + @NonNull private Context getContext() { return itemView.getContext(); } + /** + * Get the {@link ImageView} for the thumbnail image representing this MediaItem. + * @return the image view for the thumbnail. + */ + public ImageView getThumbnailImageView() { + return mIconThumb; + } + private boolean showShowOverlayGradient(@NonNull Item item) { return mCanSelectMultiple || item.isGifOrAnimatedWebp() || item.isVideo() || item.isMotionPhoto(); } + + /** Release any non-reusable resources, as the view is being recycled. */ + public void release() { + if (mSelectionOrder != null) { + mSelectionOrder.removeObservers(mLifecycleOwner); + mSelectionOrder = null; + } + } } diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java index d7568f32f..1a4f4e762 100644 --- a/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java +++ b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java @@ -16,6 +16,8 @@ package com.android.providers.media.photopicker.ui; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; + import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -31,6 +33,8 @@ import com.android.providers.media.photopicker.data.Selection; import com.android.providers.media.photopicker.data.model.Item; import com.android.providers.media.photopicker.util.DateTimeUtils; +import com.bumptech.glide.util.ViewPreloadSizeProvider; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -38,20 +42,21 @@ import java.util.List; /** * Adapts from model to something RecyclerView understands. */ -class PhotosTabAdapter extends TabAdapter { +public class PhotosTabAdapter extends TabAdapter { private static final int RECENT_MINIMUM_COUNT = 12; - + private final LifecycleOwner mLifecycleOwner; private final boolean mShowRecentSection; - private final View.OnClickListener mOnMediaItemClickListener; - private final View.OnLongClickListener mOnMediaItemLongClickListener; + private final OnMediaItemClickListener mOnMediaItemClickListener; private final Selection mSelection; + private final ViewPreloadSizeProvider mPreloadSizeProvider; + + private final View.OnHoverListener mOnMediaItemHoverListener; PhotosTabAdapter(boolean showRecentSection, @NonNull Selection selection, @NonNull ImageLoader imageLoader, - @NonNull View.OnClickListener onMediaItemClickListener, - @NonNull View.OnLongClickListener onMediaItemLongClickListener, + @NonNull OnMediaItemClickListener onMediaItemClickListener, @NonNull LifecycleOwner lifecycleOwner, @NonNull LiveData<String> cloudMediaProviderAppTitle, @NonNull LiveData<String> cloudMediaAccountName, @@ -62,16 +67,20 @@ class PhotosTabAdapter extends TabAdapter { @NonNull OnBannerEventListener onChooseAppBannerEventListener, @NonNull OnBannerEventListener onCloudMediaAvailableBannerEventListener, @NonNull OnBannerEventListener onAccountUpdatedBannerEventListener, - @NonNull OnBannerEventListener onChooseAccountBannerEventListener) { + @NonNull OnBannerEventListener onChooseAccountBannerEventListener, + @NonNull View.OnHoverListener onMediaItemHoverListener, + @NonNull ViewPreloadSizeProvider preloadSizeProvider) { super(imageLoader, lifecycleOwner, cloudMediaProviderAppTitle, cloudMediaAccountName, shouldShowChooseAppBanner, shouldShowCloudMediaAvailableBanner, shouldShowAccountUpdatedBanner, shouldShowChooseAccountBanner, onChooseAppBannerEventListener, onCloudMediaAvailableBannerEventListener, onAccountUpdatedBannerEventListener, onChooseAccountBannerEventListener); + mLifecycleOwner = lifecycleOwner; mShowRecentSection = showRecentSection; mSelection = selection; mOnMediaItemClickListener = onMediaItemClickListener; - mOnMediaItemLongClickListener = onMediaItemLongClickListener; + mOnMediaItemHoverListener = onMediaItemHoverListener; + mPreloadSizeProvider = preloadSizeProvider; } @NonNull @@ -85,10 +94,17 @@ class PhotosTabAdapter extends TabAdapter { @Override RecyclerView.ViewHolder createMediaItemViewHolder(@NonNull ViewGroup viewGroup) { final View view = getView(viewGroup, R.layout.item_photo_grid); - view.setOnClickListener(mOnMediaItemClickListener); - view.setOnLongClickListener(mOnMediaItemLongClickListener); - - return new MediaItemGridViewHolder(view, mImageLoader, mSelection.canSelectMultiple()); + final MediaItemGridViewHolder viewHolder = + new MediaItemGridViewHolder( + mLifecycleOwner, + view, + mImageLoader, + mOnMediaItemClickListener, + mOnMediaItemHoverListener, + mSelection.canSelectMultiple(), + mSelection.isSelectionOrdered()); + mPreloadSizeProvider.setView(viewHolder.getThumbnailImageView()); + return viewHolder; } @Override @@ -102,12 +118,19 @@ class PhotosTabAdapter extends TabAdapter { @Override void onBindMediaItemViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { final Item item = (Item) getAdapterItem(position); - final MediaItemGridViewHolder mediaItemVH = (MediaItemGridViewHolder) viewHolder; + final MediaItemGridViewHolder mediaItemVH = (MediaItemGridViewHolder) viewHolder; final boolean isSelected = mSelection.canSelectMultiple() && mSelection.isItemSelected(item); - mediaItemVH.bind(item, isSelected); + if (isSelected) { + mSelection.addCheckedItemIndex(item, position); + } + + mediaItemVH.bind(item, isSelected); + if (isSelected && mSelection.isSelectionOrdered()) { + mediaItemVH.setSelectionOrder(mSelection.getSelectedItemOrder(item)); + } // We also need to set Item as a tag so that OnClick/OnLongClickListeners can then // retrieve it. mediaItemVH.itemView.setTag(item); @@ -119,11 +142,15 @@ class PhotosTabAdapter extends TabAdapter { } @Override - boolean isItemTypeMediaItem(int position) { + public boolean isItemTypeMediaItem(int position) { return getAdapterItem(position) instanceof Item; } void setMediaItems(@NonNull List<Item> mediaItems) { + setMediaItems(mediaItems, ACTION_CLEAR_AND_UPDATE_LIST); + } + + void setMediaItems(@NonNull List<Item> mediaItems, @ItemsAction.Type int action) { final List<Object> mediaItemsWithDateHeaders; if (!mediaItems.isEmpty()) { // We'll have at least one section @@ -155,9 +182,7 @@ class PhotosTabAdapter extends TabAdapter { } else { mediaItemsWithDateHeaders = Collections.emptyList(); } - setAllItems(mediaItemsWithDateHeaders); - - notifyDataSetChanged(); + setAllItems(mediaItemsWithDateHeaders, action); } @VisibleForTesting @@ -186,4 +211,10 @@ class PhotosTabAdapter extends TabAdapter { } } } + + interface OnMediaItemClickListener { + void onItemClick(@NonNull View view, int position, MediaItemGridViewHolder viewHolder); + + boolean onItemLongClick(@NonNull View view, int position); + } } diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java index 8607734fe..0917b5508 100644 --- a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java +++ b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java @@ -15,13 +15,25 @@ */ package com.android.providers.media.photopicker.ui; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_LOAD_NEXT_PAGE; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; +import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_MEDIA_ITEM; import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_ALBUM_PHOTOS_TAB; import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_PHOTOS_TAB; +import android.animation.ObjectAnimator; import android.content.Context; +import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.text.TextUtils; +import android.util.Log; +import android.view.MotionEvent; import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -30,29 +42,63 @@ import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import com.android.providers.media.R; +import com.android.providers.media.photopicker.data.PaginationParameters; +import com.android.providers.media.photopicker.data.glide.PickerPreloadModelProvider; import com.android.providers.media.photopicker.data.model.Category; import com.android.providers.media.photopicker.data.model.Item; import com.android.providers.media.photopicker.util.LayoutModeUtils; +import com.android.providers.media.photopicker.util.MimeFilterUtils; +import com.android.providers.media.photopicker.viewmodel.PickerViewModel; import com.android.providers.media.util.StringUtils; +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestManager; +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; +import com.bumptech.glide.util.ViewPreloadSizeProvider; import com.google.android.material.snackbar.Snackbar; +import org.jetbrains.annotations.NotNull; + import java.text.NumberFormat; -import java.util.List; +import java.util.ArrayList; import java.util.Locale; +import java.util.Objects; /** * Photos tab fragment for showing the photos */ public class PhotosTabFragment extends TabFragment { + private static final String TAG = PhotosTabFragment.class.getSimpleName(); private static final int MINIMUM_SPAN_COUNT = 3; private static final int GRID_COLUMN_COUNT = 3; private static final String FRAGMENT_TAG = "PhotosTabFragment"; private Category mCategory = Category.DEFAULT; + private boolean mIsCurrentPageLoading = false; + + private boolean mAtLeastOnePageLoaded = false; + + private boolean mIsCloudMediaInPhotoPickerEnabled; + + private int mPageSize; + private PickerPreloadModelProvider mPreloadModelProvider; + + @Nullable + private RequestManager mGlideRequestManager = null; + + private ProgressBar mProgressBar; + private TextView mLoadingTextView; + private ObjectAnimator mObjectAnimator = new ObjectAnimator(); + private int mRecyclerViewTopPadding; + private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); + + private final Object mHideProgressBarToken = new Object(); + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -67,7 +113,13 @@ public class PhotosTabFragment extends TabFragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - final Context context = getContext(); + final Context context = requireContext(); + + // Init is only required for album content tab fragments when the fragment is not being + // recreated from a previous state. + if (savedInstanceState == null && !mCategory.isDefault()) { + mPickerViewModel.initPhotoPickerData(mCategory); + } // We only add the RECENT header on the PhotosTabFragment with CATEGORY_DEFAULT. In this // case, we call this method {loadItems} with null category. When the category is not @@ -86,26 +138,86 @@ public class PhotosTabFragment extends TabFragment { final LiveData<Boolean> showChooseAccountBanner = shouldShowBanners ? mPickerViewModel.shouldShowChooseAccountBannerLiveData() : doNotShowBanner; - final PhotosTabAdapter adapter = new PhotosTabAdapter(showRecentSection, mSelection, - mImageLoader, this::onItemClick, this::onItemLongClick, /* lifecycleOwner */ this, - mPickerViewModel.getCloudMediaProviderAppTitleLiveData(), - mPickerViewModel.getCloudMediaAccountNameLiveData(), showChooseAppBanner, - showCloudMediaAvailableBanner, showAccountUpdatedBanner, showChooseAccountBanner, - mOnChooseAppBannerEventListener, mOnCloudMediaAvailableBannerEventListener, - mOnAccountUpdatedBannerEventListener, mOnChooseAccountBannerEventListener); + mIsCloudMediaInPhotoPickerEnabled = + mPickerViewModel.getConfigStore().isCloudMediaInPhotoPickerEnabled(); + + if (savedInstanceState == null) { + initProgressBar(view); + } + mSelection.clearCheckedItemList(); + + ViewPreloadSizeProvider viewSizeProvider = new ViewPreloadSizeProvider(); + + final PhotosTabAdapter adapter = + new PhotosTabAdapter( + showRecentSection, + mSelection, + mImageLoader, + mOnMediaItemClickListener, + this, /* lifecycleOwner */ + mPickerViewModel.getCloudMediaProviderAppTitleLiveData(), + mPickerViewModel.getCloudMediaAccountNameLiveData(), + showChooseAppBanner, + showCloudMediaAvailableBanner, + showAccountUpdatedBanner, + showChooseAccountBanner, + mOnChooseAppBannerEventListener, + mOnCloudMediaAvailableBannerEventListener, + mOnAccountUpdatedBannerEventListener, + mOnChooseAccountBannerEventListener, + mOnMediaItemHoverListener, + viewSizeProvider); + + mPreloadModelProvider = new PickerPreloadModelProvider(getContext(), adapter); + mGlideRequestManager = Glide.with(this); + + RecyclerViewPreloader<Item> preloader = + new RecyclerViewPreloader<>( + Glide.with(getContext()), + mPreloadModelProvider, + viewSizeProvider, + /* maxPreload= */ 8); + mRecyclerView.addOnScrollListener(preloader); + + + // initialise pre-granted items is necessary. + Intent activityIntent = requireActivity().getIntent(); + mPickerViewModel.initialisePreGrantsIfNecessary(mPickerViewModel.getSelection(), + activityIntent.getExtras(), MimeFilterUtils.getMimeTypeFilters(activityIntent)); if (mCategory.isDefault()) { + mPageSize = mIsCloudMediaInPhotoPickerEnabled + ? PaginationParameters.PAGINATION_PAGE_SIZE_ITEMS : -1; setEmptyMessage(R.string.picker_photos_empty_message); // Set the pane title for A11y view.setAccessibilityPaneTitle(getString(R.string.picker_photos)); - mPickerViewModel.getItems() - .observe(this, itemList -> onChangeMediaItems(itemList, adapter)); + // Get items with pagination parameters representing the first page. + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, + new PaginationParameters( + mPageSize, + /* dateBeforeMs */ Long.MIN_VALUE, + /* rowId */ -1)) + .observe(this, itemListResult -> { + onChangeMediaItems(itemListResult, adapter); + }); } else { + mPageSize = mIsCloudMediaInPhotoPickerEnabled + ? PaginationParameters.PAGINATION_PAGE_SIZE_ALBUM_ITEMS : -1; setEmptyMessage(R.string.picker_album_media_empty_message); // Set the pane title for A11y view.setAccessibilityPaneTitle(mCategory.getDisplayName(context)); - mPickerViewModel.getCategoryItems(mCategory) - .observe(this, itemList -> onChangeMediaItems(itemList, adapter)); + // Get items with pagination parameters representing the first page. + mPickerViewModel.getPaginatedCategoryItemsForAction( + mCategory, + ACTION_VIEW_CREATED, + new PaginationParameters( + mPageSize, + /* dateBeforeMs */ Long.MIN_VALUE, + /* rowId */ -1)) + .observe(this, itemListResult -> { + onChangeMediaItems(itemListResult, adapter); + }); } final PhotosTabItemDecoration itemDecoration = new PhotosTabItemDecoration(context); @@ -115,9 +227,118 @@ public class PhotosTabFragment extends TabFragment { mRecyclerView.setColumnWidth(photoSize + spacing); mRecyclerView.setMinimumSpanCount(MINIMUM_SPAN_COUNT); - setLayoutManager(adapter, GRID_COLUMN_COUNT); + setLayoutManager(context, adapter, GRID_COLUMN_COUNT); mRecyclerView.setAdapter(adapter); mRecyclerView.addItemDecoration(itemDecoration); + + mRecyclerView.addRecyclerListener( + new RecyclerView.RecyclerListener() { + @Override + public void onViewRecycled(RecyclerView.ViewHolder holder) { + if (mGlideRequestManager != null + && holder.getItemViewType() == ITEM_TYPE_MEDIA_ITEM) { + // This cast is safe as we've already checked the view type is + MediaItemGridViewHolder vh = (MediaItemGridViewHolder) holder; + // Cancel pending glide load requests on recycling, to prevent a large + // backlog of requests building up in the event of large scrolls. + cancelGlideLoadForViewHolder(vh); + vh.release(); + } + } + }); + mRecyclerView.setItemViewCacheSize(10); + + if (mIsCloudMediaInPhotoPickerEnabled) { + setOnScrollListenerForRecyclerView(); + } + + // uncheck the unavailable items at UI those are no longer available in the selection list + requirePickerActivity().isItemPhotoGridViewChanged() + .observe(this, isItemViewChanged -> { + if (isItemViewChanged) { + // To re-bind the view just to uncheck the unavailable media items at UI + // Size of mCheckItems is going to be constant ( Iterating over mCheckItems + // is not a heavy operation) + for (Integer index : mSelection.getCheckedItemsIndexes()) { + adapter.notifyItemChanged(index); + } + } + }); + } + + private void initProgressBar(@NonNull View view) { + // Check feature flag for cloud media and if it is not true then hide progress bar and + // loading text. + if (mIsCloudMediaInPhotoPickerEnabled) { + mLoadingTextView = view.findViewById(R.id.loading_text_view); + mProgressBar = view.findViewById(R.id.progress_bar); + mRecyclerViewTopPadding = getResources().getDimensionPixelSize( + R.dimen.picker_recycler_view_top_padding); + if (mCategory == Category.DEFAULT) { + mPickerViewModel.isSyncInProgress().observe(this, inProgress -> { + if (inProgress) { + bringProgressBarAndLoadingTextInView(); + } + }); + } else { + bringProgressBarAndLoadingTextInView(); + } + } + } + private void setOnScrollListenerForRecyclerView() { + mRecyclerView.addOnScrollListener( + new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull @NotNull RecyclerView recyclerView, int dx, + int dy) { + super.onScrolled(recyclerView, dx, dy); + + // check to ensure that the current page is not still loading and the last + // page has not been loaded. + if (!mIsCurrentPageLoading) { + LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + + assert layoutManager != null; + // Total items visible at the screen at any current time. + int visibleItemCount = layoutManager.getChildCount(); + // Total items in the layout. + int totalItemCount = layoutManager.getItemCount(); + // The position of the first visible view + int firstVisibleItemPosition = + layoutManager.findFirstVisibleItemPosition(); + + // If the number of items have exceeded the threshold, a call will be + // triggered to load the next page. + int thresholdNumberOfItems = totalItemCount - mPageSize; + if (visibleItemCount + firstVisibleItemPosition + >= thresholdNumberOfItems + && firstVisibleItemPosition >= 0 + ) { + + Log.d(FRAGMENT_TAG, "Scrolled beyond page threshold, sending a" + + " call to load the next page."); + + // setting this to true ensures that only one call is sent on + // crossing the threshold and only required number of pages are + // loaded. + mIsCurrentPageLoading = true; + if (mCategory.isDefault()) { + mPickerViewModel.getPaginatedItemsForAction( + ACTION_LOAD_NEXT_PAGE, + null); + } else { + mPickerViewModel.getPaginatedCategoryItemsForAction( + mCategory, + ACTION_LOAD_NEXT_PAGE, + null); + } + } + } + + } + }); + } /** @@ -133,82 +354,168 @@ public class PhotosTabFragment extends TabFragment { @Override public void onResume() { super.onResume(); - final String title; final LayoutModeUtils.Mode layoutMode; final boolean shouldHideProfileButton; + if (mCategory.isDefault()) { title = ""; layoutMode = MODE_PHOTOS_TAB; shouldHideProfileButton = false; } else { - title = mCategory.getDisplayName(getContext()); + title = mCategory.getDisplayName(requireContext()); layoutMode = MODE_ALBUM_PHOTOS_TAB; shouldHideProfileButton = true; } + requirePickerActivity().updateCommonLayouts(layoutMode, title); - getPickerActivity().updateCommonLayouts(layoutMode, title); hideProfileButton(shouldHideProfileButton); + + if (mIsCloudMediaInPhotoPickerEnabled + && mCategory == Category.DEFAULT + && mAtLeastOnePageLoaded) { + // mAtLeastOnePageLoaded is checked to avoid calling this method while the view is + // being created + LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + + if (layoutManager != null) { + int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition(); + mPickerViewModel.getPaginatedItemsForAction( + ACTION_REFRESH_ITEMS, + new PaginationParameters(firstVisibleItemPosition + + PaginationParameters.PAGINATION_PAGE_SIZE_ITEMS, + /*dateBeforeMs*/ Long.MIN_VALUE, -1)); + } + } } - private void onChangeMediaItems(@NonNull List<Item> itemList, + private void onChangeMediaItems(@NonNull PickerViewModel.PaginatedItemsResult itemList, @NonNull PhotosTabAdapter adapter) { - adapter.setMediaItems(itemList); - // Handle emptyView's visibility - updateVisibilityForEmptyView(/* shouldShowEmptyView */ itemList.size() == 0); + Objects.requireNonNull(itemList); + if (isClearGridAction(itemList)) { + adapter.setMediaItems(new ArrayList<>(), itemList.getAction()); + updateVisibilityForEmptyView(false); + } else { + adapter.setMediaItems(itemList.getItems(), itemList.getAction()); + // Handle emptyView's visibility + boolean shouldShowEmptyView = (itemList.getItems().size() == 0); + updateVisibilityForEmptyView(shouldShowEmptyView); + if (shouldShowEmptyView) { + mPickerViewModel.setEmptyPageDisplayed(true); + } + } + mIsCurrentPageLoading = false; + mAtLeastOnePageLoaded = true; + hideProgressBarAndLoadingText(); + } + + private boolean isClearGridAction(@NonNull PickerViewModel.PaginatedItemsResult itemList) { + return itemList.getItems() != null + && itemList.getItems().size() == 1 + && itemList.getItems().get(0).getId().equals("EMPTY_VIEW"); } - private void onItemClick(@NonNull View view) { - if (mSelection.canSelectMultiple()) { - final boolean isSelectedBefore = view.isSelected(); + private final PhotosTabAdapter.OnMediaItemClickListener mOnMediaItemClickListener = + new PhotosTabAdapter.OnMediaItemClickListener() { + @Override + public void onItemClick( + @NonNull View view, int position, MediaItemGridViewHolder viewHolder) { - if (isSelectedBefore) { - mSelection.removeSelectedItem((Item) view.getTag()); - } else { - if (!mSelection.isSelectionAllowed()) { - final int maxCount = mSelection.getMaxSelectionLimit(); - final CharSequence quantityText = - StringUtils.getICUFormatString( - getResources(), maxCount, R.string.select_up_to); - final String itemCountString = NumberFormat.getInstance(Locale.getDefault()) - .format(maxCount); - final CharSequence message = TextUtils.expandTemplate(quantityText, - itemCountString); - Snackbar.make(view, message, Snackbar.LENGTH_SHORT).show(); - return; - } else { + if (mSelection.canSelectMultiple()) { + final boolean isSelectedBefore = + mSelection.isItemSelected((Item) view.getTag()) + && view.isSelected(); + + Item item = (Item) view.getTag(); + if (isSelectedBefore) { + if (mSelection.isSelectionOrdered()) { + viewHolder.setSelectionOrder(null); + } + mSelection.removeSelectedItem((Item) view.getTag()); + mSelection.removeCheckedItemIndex((Item) view.getTag()); + } else { + mSelection.addCheckedItemIndex((Item) view.getTag(), position); + if (!mSelection.isSelectionAllowed()) { + final int maxCount = mSelection.getMaxSelectionLimit(); + final CharSequence quantityText = + StringUtils.getICUFormatString( + getResources(), maxCount, R.string.select_up_to); + final String itemCountString = + NumberFormat.getInstance(Locale.getDefault()) + .format(maxCount); + final CharSequence message = + TextUtils.expandTemplate(quantityText, itemCountString); + Snackbar.make(view, message, Snackbar.LENGTH_SHORT).show(); + return; + } else { + mSelection.addSelectedItem(item); + if (mSelection.isSelectionOrdered()) { + viewHolder.setSelectionOrder( + mSelection.getSelectedItemOrder(item)); + } + mPickerViewModel.logMediaItemSelected(item, mCategory, position); + } + } + view.setSelected(!isSelectedBefore); + + // There is an issue b/223695510 about not selected in Accessibility mode. + // It only says selected state, but it doesn't say not selected state. + // Add the not selected only to avoid that it says selected twice. + view.setStateDescription( + isSelectedBefore ? getString(R.string.not_selected) : null); + } else { + final Item item = (Item) view.getTag(); + mSelection.setSelectedItem(item); + mPickerViewModel.logMediaItemSelected(item, mCategory, position); + try { + requirePickerActivity().setResultAndFinishSelf(); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } + } + } + + @Override + public boolean onItemLongClick(@NonNull View view, int position) { final Item item = (Item) view.getTag(); - mSelection.addSelectedItem(item); + if (!mSelection.canSelectMultiple()) { + // In single select mode, if the item is previewed, we set it as selected + // item. This assists in "Add" button click to return all selected items. + // For multi select, long click only previews the item, and until user + // selects the item, it doesn't get added to selected items. Also, there is + // no "Add" button in the preview layout that can return selected items. + mSelection.setSelectedItem(item); + } + mSelection.prepareItemForPreviewOnLongPress(item); + mPickerViewModel.logMediaItemPreviewed(item, mCategory, position); + + try { + // Transition to PreviewFragment. + PreviewFragment.show( + requireActivity().getSupportFragmentManager(), + PreviewFragment.getArgsForPreviewOnLongPress()); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } + + // Consume the long click so that it doesn't propagate in the View hierarchy. + return true; } - } - view.setSelected(!isSelectedBefore); - // There is an issue b/223695510 about not selected in Accessibility mode. It only says - // selected state, but it doesn't say not selected state. Add the not selected only to - // avoid that it says selected twice. - view.setStateDescription(isSelectedBefore ? getString(R.string.not_selected) : null); - } else { - final Item item = (Item) view.getTag(); - mSelection.setSelectedItem(item); - getPickerActivity().setResultAndFinishSelf(); - } - } + }; - private boolean onItemLongClick(@NonNull View view) { - final Item item = (Item) view.getTag(); - if (!mSelection.canSelectMultiple()) { - // In single select mode, if the item is previewed, we set it as selected item. This is - // will assist in "Add" button click to return all selected items. - // For multi select, long click only previews the item, and until user selects the item, - // it doesn't get added to selected items. Also, there is no "Add" button in the preview - // layout that can return selected items. - mSelection.setSelectedItem(item); + public View.OnHoverListener mOnMediaItemHoverListener = (v, event) -> { + // When a cursor is hovered over an item the item should appear selected and when the + // cursor moves out of the bounds of the view, it should go back to being unselected. + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + v.setSelected(true); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + if (!mSelection.isItemSelected((Item) v.getTag())) { + v.setSelected(false); + } } - mSelection.prepareItemForPreviewOnLongPress(item); - // Transition to PreviewFragment. - PreviewFragment.show(getActivity().getSupportFragmentManager(), - PreviewFragment.getArgsForPreviewOnLongPress()); return true; - } + }; /** * Create the fragment with the category and add it into the FragmentManager @@ -235,4 +542,85 @@ public class PhotosTabFragment extends TabFragment { public static Fragment get(FragmentManager fm) { return fm.findFragmentByTag(FRAGMENT_TAG); } + + /** + * Hides progress bar and the loading photos message. + * <p>This is executed with a delay of 0.6ms. + * This is done so that for the cases where the loading happens very quickly the user will not + * see the progressBar flicker.</p> + * + * <p>This results in progressBar and loadingText to remain in view for loadingTime + 0.6ms.</p> + */ + private synchronized void hideProgressBarAndLoadingText() { + if (mProgressBar != null + && mLoadingTextView != null + && mProgressBar.getVisibility() == View.VISIBLE + && mLoadingTextView.getVisibility() == View.VISIBLE) { + // clear previous calls, extra caution. + mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken); + Runnable runnable = new Runnable() { + @Override + public void run() { + if (mProgressBar != null + && mLoadingTextView != null + && mProgressBar.getVisibility() == View.VISIBLE + && mLoadingTextView.getVisibility() == View.VISIBLE) { + mProgressBar.setVisibility(View.GONE); + mLoadingTextView.setVisibility(View.GONE); + // Move recyclerView up to cover up the space taken up by progressBar and + // loadingTest. + if (mRecyclerView != null + && mRecyclerView.getVisibility() == View.VISIBLE) { + mObjectAnimator.ofFloat( + mRecyclerView, + /* property name */ "y", + /* final position */0f) + .setDuration(300).start(); + } + } + } + }; + // With this runnable the hiding of progress bar is delayed by 600ms. + mMainThreadHandler.postDelayed(runnable, mHideProgressBarToken, /* delay duration */ + 600); + } + } + + private void bringProgressBarAndLoadingTextInView() { + if (mIsCloudMediaInPhotoPickerEnabled) { + if (mObjectAnimator != null) { + // stop any pending/ongoing animations. + mObjectAnimator.cancel(); + } + if (mRecyclerView.getVisibility() == View.VISIBLE) { + // move recycler view down to make space for progress bar and loading text. + mObjectAnimator.ofFloat( + mRecyclerView, + /* property name */ "y", + /* final position */mRecyclerViewTopPadding) + .setDuration(1).start(); + } + // bring progressBar and Loading text in view. + mLoadingTextView.setVisibility(View.VISIBLE); + mProgressBar.setVisibility(View.VISIBLE); + } + } + + /** + * Attempts to cancel any outstanding Glide requests for the given ViewHolder. + * + * @param holder The View holder in the RecyclerView to cancel requests for. + */ + private void cancelGlideLoadForViewHolder(MediaItemGridViewHolder vh) { + // Attempt to clear the potential pending load out of glide's request + // manager. + mGlideRequestManager.clear(vh.getThumbnailImageView()); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken); + mGlideRequestManager = null; + } } diff --git a/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java b/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java index 1df48777f..d00aad97c 100644 --- a/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java +++ b/src/com/android/providers/media/photopicker/ui/PreviewAdapter.java @@ -33,7 +33,7 @@ import java.util.List; /** * Adapter for Preview RecyclerView to preview all images and videos. */ -class PreviewAdapter extends RecyclerView.Adapter<BaseViewHolder> { +public class PreviewAdapter extends RecyclerView.Adapter<BaseViewHolder> { private static final int ITEM_TYPE_IMAGE = 1; private static final int ITEM_TYPE_VIDEO = 2; @@ -41,10 +41,15 @@ class PreviewAdapter extends RecyclerView.Adapter<BaseViewHolder> { private List<Item> mItemList = new ArrayList<>(); private final ImageLoader mImageLoader; private final RemotePreviewHandler mRemotePreviewHandler; + private final OnVideoPreviewClickListener mOnVideoPreviewClickListener; - PreviewAdapter(Context context, MuteStatus muteStatus) { + PreviewAdapter(Context context, MuteStatus muteStatus, + @NonNull OnCreateSurfaceController onCreateSurfaceController, + @NonNull OnVideoPreviewClickListener onVideoPreviewClickListener) { mImageLoader = new ImageLoader(context); - mRemotePreviewHandler = new RemotePreviewHandler(context, muteStatus); + mRemotePreviewHandler = new RemotePreviewHandler(context, muteStatus, + onCreateSurfaceController); + mOnVideoPreviewClickListener = onVideoPreviewClickListener; } @NonNull @@ -53,7 +58,8 @@ class PreviewAdapter extends RecyclerView.Adapter<BaseViewHolder> { if (viewType == ITEM_TYPE_IMAGE) { return new PreviewImageHolder(viewGroup.getContext(), viewGroup, mImageLoader); } - return new PreviewVideoHolder(viewGroup.getContext(), viewGroup, mImageLoader); + return new PreviewVideoHolder(viewGroup.getContext(), viewGroup, mImageLoader, + mOnVideoPreviewClickListener); } @Override @@ -112,4 +118,25 @@ class PreviewAdapter extends RecyclerView.Adapter<BaseViewHolder> { mItemList = itemList; notifyDataSetChanged(); } + + interface OnVideoPreviewClickListener { + void logMuteButtonClick(); + } + + /** + * Log metrics related to the surface controller creation + */ + public interface OnCreateSurfaceController { + /** + * Log metrics to notify create surface controller triggered + * @param authority the authority of the provider + */ + void logStart(String authority); + + /** + * Log metrics to notify create surface controller ended + * @param authority the authority of the provider + */ + void logEnd(String authority); + } } diff --git a/src/com/android/providers/media/photopicker/ui/PreviewFragment.java b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java index 57c7e5398..a70e8fb9e 100644 --- a/src/com/android/providers/media/photopicker/ui/PreviewFragment.java +++ b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java @@ -48,6 +48,7 @@ import com.android.providers.media.photopicker.viewmodel.PickerViewModel; import java.text.NumberFormat; import java.util.List; import java.util.Locale; +import java.util.Objects; /** * Displays a selected items in one up view. Supports deselecting items. @@ -114,11 +115,7 @@ public class PreviewFragment extends Fragment { final List<Item> selectedItemsList = mSelection.getSelectedItemsForPreview(); final int selectedItemsListSize = selectedItemsList.size(); - if (selectedItemsListSize <= 0) { - // This can happen if we lost PickerViewModel to optimize memory. - Log.e(TAG, "No items to preview. Returning back to photo grid"); - requireActivity().getSupportFragmentManager().popBackStack(); - } else if (selectedItemsListSize > 1 && !mSelection.canSelectMultiple()) { + if (selectedItemsListSize > 1 && !mSelection.canSelectMultiple()) { // This should never happen throw new IllegalStateException("Found more than one preview items in single select" + " mode. Selected items count: " + selectedItemsListSize); @@ -130,10 +127,40 @@ public class PreviewFragment extends Fragment { throw new IllegalStateException("Expected to find ViewPager2 in " + view + ", but found null"); } - mViewPager2Wrapper = new ViewPager2Wrapper(viewPager, selectedItemsList, mMuteStatus); + mViewPager2Wrapper = new ViewPager2Wrapper(viewPager, selectedItemsList, mMuteStatus, + mOnCreateSurfaceController, mPickerViewModel::logVideoPreviewMuteButtonClick); setUpPreviewLayout(view, getArguments()); setupScrimLayerAndBottomBar(view); + // Don't add any code post this line. The lazy loading setup should be the last thing we do + // to avoid the UI getting overwritten. + setUpUIForLazyLoading(view, selectedItemsListSize); + } + + private void setUpUIForLazyLoading(View view, int selectedItemsListSize) { + final Button selectedCheckButton = view.findViewById(R.id.preview_selected_check_button); + Objects.requireNonNull(selectedCheckButton); + if (selectedItemsListSize == 0) { + // This can happen in two cases - + // 1. ACTION_USER_SELECT_IMAGES_FOR_APP launched the Photo Picker UI, and we are waiting + // for items that's not preloaded due to pagination + // 2. PreviewFragment was launched from SavedPreference state but PickerViewModel was + // killed and hence there is no selected items. + // In both these cases, user will see a blank UI with only Add/Allow button + selectedCheckButton.setVisibility(View.GONE); + Log.i(TAG, "No items to preview yet" + selectedCheckButton.getVisibility()); + } + + if (mPickerViewModel.isManagedSelectionEnabled()) { + mPickerViewModel.getIsAllPreGrantedMediaLoaded().observe(this, (isLoadComplete) -> { + if (!isLoadComplete) return; + + selectedCheckButton.setVisibility(View.VISIBLE); + + mSelection.prepareSelectedItemsForPreviewAll(); + mViewPager2Wrapper.updateList(mSelection.getSelectedItemsForPreview()); + }); + } } private void setupScrimLayerAndBottomBar(View fragmentView) { @@ -200,7 +227,7 @@ public class PreviewFragment extends Fragment { // For preview on long press, we always preview only one item. // Selection#getSelectedItemsForPreview is guaranteed to return only one item. Hence, // we can always use position=0 as current position. - updateSelectButtonText(addOrSelectButton, + updateSelectButtonTextAndVisibility(addOrSelectButton, mSelection.isItemSelected(mViewPager2Wrapper.getItemAt(/* position */ 0))); addOrSelectButton.setOnClickListener(v -> onClickSelectButton(addOrSelectButton)); } @@ -242,7 +269,9 @@ public class PreviewFragment extends Fragment { /* context= */ getContext(), /* size= */ selectedItemCount, /* isUserSelectForApp= */ mPickerViewModel - .isUserSelectForApp())); + .isUserSelectForApp(), + /* isManagedSelectionEnabled */ + mPickerViewModel.isManagedSelectionEnabled())); }); selectedCheckButton.setOnClickListener( @@ -285,7 +314,7 @@ public class PreviewFragment extends Fragment { private void onClickSelectButton(@NonNull Button selectButton) { final boolean isSelectedNow = updateSelectionAndGetState(); - updateSelectButtonText(selectButton, isSelectedNow); + updateSelectButtonTextAndVisibility(selectButton, isSelectedNow); } private void onClickSelectedCheckButton(@NonNull Button selectedCheckButton) { @@ -337,9 +366,11 @@ public class PreviewFragment extends Fragment { } } - private static void updateSelectButtonText(@NonNull Button selectButton, + private void updateSelectButtonTextAndVisibility(@NonNull Button selectButton, boolean isSelected) { selectButton.setText(isSelected ? R.string.deselect : R.string.select); + selectButton.setVisibility( + (isSelected || mSelection.isSelectionAllowed()) ? View.VISIBLE : View.GONE); } private static void updateSelectedCheckButtonStateAndText(@NonNull Button selectedCheckButton, @@ -388,7 +419,11 @@ public class PreviewFragment extends Fragment { // TODO: There is a same method in TabFragment. To find a way to reuse it. private static String generateAddButtonString( - @NonNull Context context, int size, boolean isUserSelectForApp) { + @NonNull Context context, int size, boolean isUserSelectForApp, + boolean isManagedSelection) { + if (isManagedSelection && size == 0) { + return context.getString(R.string.picker_add_button_allow_none_option); + } final String sizeString = NumberFormat.getInstance(Locale.getDefault()).format(size); final String template = isUserSelectForApp @@ -396,4 +431,17 @@ public class PreviewFragment extends Fragment { : context.getString(R.string.picker_add_button_multi_select); return TextUtils.expandTemplate(template, sizeString).toString(); } + + private final PreviewAdapter.OnCreateSurfaceController mOnCreateSurfaceController = + new PreviewAdapter.OnCreateSurfaceController() { + @Override + public void logStart(String authority) { + mPickerViewModel.logCreateSurfaceControllerStart(authority); + } + + @Override + public void logEnd(String authority) { + mPickerViewModel.logCreateSurfaceControllerEnd(authority); + } + }; } diff --git a/src/com/android/providers/media/photopicker/ui/PreviewVideoHolder.java b/src/com/android/providers/media/photopicker/ui/PreviewVideoHolder.java index 162f6d1b2..4da12b94b 100644 --- a/src/com/android/providers/media/photopicker/ui/PreviewVideoHolder.java +++ b/src/com/android/providers/media/photopicker/ui/PreviewVideoHolder.java @@ -24,6 +24,7 @@ import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ImageView; +import androidx.annotation.NonNull; import androidx.viewpager2.widget.ViewPager2; import com.android.providers.media.R; @@ -38,6 +39,7 @@ import com.google.android.material.progressindicator.CircularProgressIndicator; public class PreviewVideoHolder extends BaseViewHolder { private final ImageLoader mImageLoader; + private final PreviewAdapter.OnVideoPreviewClickListener mOnVideoPreviewClickListener; private final ImageView mImageView; private final SurfaceView mSurfaceView; private final AspectRatioFrameLayout mPlayerFrame; @@ -47,10 +49,12 @@ public class PreviewVideoHolder extends BaseViewHolder { private final ImageButton mMuteButton; private final CircularProgressIndicator mCircularProgressIndicator; - PreviewVideoHolder(Context context, ViewGroup parent, ImageLoader imageLoader) { + PreviewVideoHolder(Context context, ViewGroup parent, ImageLoader imageLoader, + @NonNull PreviewAdapter.OnVideoPreviewClickListener onVideoPreviewClickListener) { super(context, parent, R.layout.item_video_preview); mImageLoader = imageLoader; + mOnVideoPreviewClickListener = onVideoPreviewClickListener; mImageView = itemView.findViewById(R.id.preview_video_image); mSurfaceView = itemView.findViewById(R.id.preview_player_view); mPlayerFrame = itemView.findViewById(R.id.preview_player_frame); @@ -103,4 +107,11 @@ public class PreviewVideoHolder extends BaseViewHolder { public CircularProgressIndicator getCircularProgressIndicator() { return mCircularProgressIndicator; } + + /** + * Log metrics to notify that the user has clicked the mute / unmute button in a video preview + */ + public void logMuteButtonClick() { + mOnVideoPreviewClickListener.logMuteButtonClick(); + } } diff --git a/src/com/android/providers/media/photopicker/ui/TEST_MAPPING b/src/com/android/providers/media/photopicker/ui/TEST_MAPPING new file mode 100644 index 000000000..842d03542 --- /dev/null +++ b/src/com/android/providers/media/photopicker/ui/TEST_MAPPING @@ -0,0 +1,26 @@ +{ + "mainline-presubmit": [ + { + "name": "MediaProviderTests[com.google.android.mediaprovider.apex]", + "options": [ + { + // For changes in Photopicker UI we want to run all the photopicker + // tests in the given package regardless of @RunOnlyOnPostsubmit annotation + "include-filter": "com.android.providers.media.photopicker" + } + ] + } + ], + "presubmit": [ + { + "name": "MediaProviderTests", + "options": [ + { + // For changes in Photopicker UI we want to run all the photopicker + // tests in the given package regardless of @RunOnlyOnPostsubmit annotation + "include-filter": "com.android.providers.media.photopicker" + } + ] + } + ] +} diff --git a/src/com/android/providers/media/photopicker/ui/TabAdapter.java b/src/com/android/providers/media/photopicker/ui/TabAdapter.java index 643e2800d..86f2de7ee 100644 --- a/src/com/android/providers/media/photopicker/ui/TabAdapter.java +++ b/src/com/android/providers/media/photopicker/ui/TabAdapter.java @@ -16,7 +16,15 @@ package com.android.providers.media.photopicker.ui; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_GRID; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_LOAD_NEXT_PAGE; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; +import static com.android.providers.media.photopicker.viewmodel.PickerViewModel.TAG; + import android.content.Context; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -39,15 +47,14 @@ import java.util.List; /** * Adapts from model to something RecyclerView understands. */ -@VisibleForTesting public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { @VisibleForTesting public static final int ITEM_TYPE_BANNER = 0; // Date header sections for "Photos" tab - static final int ITEM_TYPE_SECTION = 1; + public static final int ITEM_TYPE_SECTION = 1; // Media items (a.k.a. Items) for "Photos" tab, Albums (a.k.a. Categories) for "Albums" tab - private static final int ITEM_TYPE_MEDIA_ITEM = 2; + public static final int ITEM_TYPE_MEDIA_ITEM = 2; @NonNull final ImageLoader mImageLoader; @NonNull private final LiveData<String> mCloudMediaProviderAppTitle; @@ -209,7 +216,7 @@ public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewH mBanner = banner; mOnBannerEventListener = onBannerEventListener; notifyItemInserted(/* position */ 0); - mOnBannerEventListener.onBannerAdded(); + mOnBannerEventListener.onBannerAdded(banner.name()); } else { mBanner = banner; mOnBannerEventListener = onBannerEventListener; @@ -225,14 +232,47 @@ public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewH /** * Update the List of all items (excluding the banner) in tab adapter {@link #mAllItems} */ - protected final void setAllItems(@NonNull List<?> items) { + protected final void setAllItems(@NonNull List<?> items, + @ItemsAction.Type int action) { + int previousItemCount = getItemCount(); mAllItems.clear(); mAllItems.addAll(items); - notifyDataSetChanged(); + notifyOnListChanged(previousItemCount, items.size(), action); + } + + private void notifyOnListChanged(int previousItemCount, int sizeOfUpdatedList, + @ItemsAction.Type int action) { + Log.d(TAG, "Updating adapter for action: " + action); + switch (action) { + case ACTION_VIEW_CREATED: + case ACTION_CLEAR_AND_UPDATE_LIST: { + notifyDataSetChanged(); + break; + } + case ACTION_CLEAR_GRID: { + notifyItemRangeRemoved(0, previousItemCount); + break; + } + case ACTION_LOAD_NEXT_PAGE: { + notifyItemRangeInserted(previousItemCount, + sizeOfUpdatedList - previousItemCount); + break; + } + case ACTION_REFRESH_ITEMS: { + notifyItemRangeChanged(0, sizeOfUpdatedList); + if (sizeOfUpdatedList < previousItemCount) { + notifyItemRangeRemoved(sizeOfUpdatedList, + previousItemCount - sizeOfUpdatedList); + } + break; + } + default: + Log.w(TAG, "Invalid action passed. No update to adapter"); + } } @NonNull - final Object getAdapterItem(int position) { + public final Object getAdapterItem(int position) { if (position < 0) { throw new IllegalStateException("Get adapter item for negative position " + position); } @@ -276,7 +316,7 @@ public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewH mDismissButton.setOnClickListener(v -> onBannerEventListener.onDismissButtonClick()); - if (banner.mActionButtonText != -1) { + if (banner.mActionButtonText != -1 && onBannerEventListener.shouldShowActionButton()) { mActionButton.setText(banner.mActionButtonText); mActionButton.setVisibility(View.VISIBLE); mActionButton.setOnClickListener(v -> onBannerEventListener.onActionButtonClick()); @@ -287,18 +327,14 @@ public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewH } private enum Banner { - // TODO(b/274426228): Replace `CLOUD_MEDIA_AVAILABLE` `mActionButtonText` from `-1` to - // `R.string.picker_banner_cloud_change_account_button`, post change cloud account - // functionality implementation from the Picker settings (b/261999521). CLOUD_MEDIA_AVAILABLE(R.string.picker_banner_cloud_first_time_available_title, - R.string.picker_banner_cloud_first_time_available_desc, /* no action button */ -1), + R.string.picker_banner_cloud_first_time_available_desc, + R.string.picker_banner_cloud_change_account_button), ACCOUNT_UPDATED(R.string.picker_banner_cloud_account_changed_title, R.string.picker_banner_cloud_account_changed_desc, /* no action button */ -1), - // TODO(b/274426228): Replace `CHOOSE_ACCOUNT` `mActionButtonText` from `-1` to - // `R.string.picker_banner_cloud_choose_account_button`, post change cloud account - // functionality implementation from the Picker settings (b/261999521). CHOOSE_ACCOUNT(R.string.picker_banner_cloud_choose_account_title, - R.string.picker_banner_cloud_choose_account_desc, /* no action button */ -1), + R.string.picker_banner_cloud_choose_account_desc, + R.string.picker_banner_cloud_choose_account_button), CHOOSE_APP(R.string.picker_banner_cloud_choose_app_title, R.string.picker_banner_cloud_choose_app_desc, R.string.picker_banner_cloud_choose_app_button); @@ -349,10 +385,12 @@ public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewH void onDismissButtonClick(); - default void onBannerClick() { - onActionButtonClick(); - } + void onBannerClick(); - void onBannerAdded(); + void onBannerAdded(@NonNull String name); + + default boolean shouldShowActionButton() { + return true; + } } } diff --git a/src/com/android/providers/media/photopicker/ui/TabContainerFragment.java b/src/com/android/providers/media/photopicker/ui/TabContainerFragment.java index 5ec4d65ef..8e070b5b1 100644 --- a/src/com/android/providers/media/photopicker/ui/TabContainerFragment.java +++ b/src/com/android/providers/media/photopicker/ui/TabContainerFragment.java @@ -15,6 +15,8 @@ */ package com.android.providers.media.photopicker.ui; +import static com.android.providers.media.util.MimeUtils.isVideoMimeType; + import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -26,11 +28,14 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.CompositePageTransformer; import androidx.viewpager2.widget.ViewPager2; import com.android.providers.media.R; +import com.android.providers.media.photopicker.util.MimeFilterUtils; +import com.android.providers.media.photopicker.viewmodel.PickerViewModel; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.tabs.TabLayout; @@ -50,6 +55,7 @@ public class TabContainerFragment extends Fragment { private TabContainerAdapter mTabContainerAdapter; private TabLayoutMediator mTabLayoutMediator; private ViewPager2 mViewPager; + private PickerViewModel mPickerViewModel; @Override @NonNull @@ -65,6 +71,8 @@ public class TabContainerFragment extends Fragment { mTabContainerAdapter = new TabContainerAdapter(/* fragment */ this); mViewPager = view.findViewById(R.id.picker_tab_viewpager); mViewPager.setAdapter(mTabContainerAdapter); + final ViewModelProvider viewModelProvider = new ViewModelProvider(requireActivity()); + mPickerViewModel = viewModelProvider.get(PickerViewModel.class); // If the ViewPager2 has more than one page with BottomSheetBehavior, the scrolled view // (e.g. RecyclerView) on the second page can't be scrolled. The workaround is to update @@ -96,18 +104,41 @@ public class TabContainerFragment extends Fragment { } final TabLayout tabLayout = getActivity().findViewById(R.id.tab_layout); + mTabLayoutMediator = new TabLayoutMediator(tabLayout, mViewPager, (tab, pos) -> { if (pos == PHOTOS_TAB_POSITION) { - tab.setText(R.string.picker_photos); + if (isOnlyVideoMimeTypeFilterAvailable()) { + tab.setText(R.string.picker_videos); + } else { + tab.setText(R.string.picker_photos); + } } else if (pos == ALBUMS_TAB_POSITION) { tab.setText(R.string.picker_albums); } }); + mTabLayoutMediator.attach(); // TabLayout only supports colorDrawable in xml. And if we set the color in the drawable by // setSelectedTabIndicator method, it doesn't apply the color. So, we set color in xml and // set the drawable for the shape here. tabLayout.setSelectedTabIndicator(R.drawable.picker_tab_indicator); + tabLayout.addOnTabSelectedListener(mOnTabSelectedListener); + } + + private boolean isOnlyVideoMimeTypeFilterAvailable() { + String [] mimeTypeFilters = MimeFilterUtils.getMimeTypeFilters(getActivity().getIntent()); + boolean hasVideoMimeTypeFilterOnly = false; + if (mimeTypeFilters != null && mimeTypeFilters.length > 0) { + for (String mimeTypeFilter : mimeTypeFilters) { + if (isVideoMimeType(mimeTypeFilter)) { + hasVideoMimeTypeFilterOnly = true; + } else { + hasVideoMimeTypeFilterOnly = false; + break; + } + } + } + return hasVideoMimeTypeFilterOnly; } @Override @@ -128,6 +159,29 @@ public class TabContainerFragment extends Fragment { ft.commitAllowingStateLoss(); } + private final TabLayout.OnTabSelectedListener mOnTabSelectedListener = + new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + int position = tab.getPosition(); + if (position == PHOTOS_TAB_POSITION) { + mPickerViewModel.logSwitchToPhotosTab(); + } else if (position == ALBUMS_TAB_POSITION) { + mPickerViewModel.logSwitchToAlbumsTab(); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + // No=op + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + // No-op + } + }; + private static class AnimationPageTransformer implements ViewPager2.PageTransformer { @Override diff --git a/src/com/android/providers/media/photopicker/ui/TabFragment.java b/src/com/android/providers/media/photopicker/ui/TabFragment.java index 90bb43979..14159c293 100644 --- a/src/com/android/providers/media/photopicker/ui/TabFragment.java +++ b/src/com/android/providers/media/photopicker/ui/TabFragment.java @@ -24,12 +24,14 @@ import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_SE import android.app.admin.DevicePolicyManager; import android.content.Context; +import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -44,6 +46,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; @@ -66,7 +69,7 @@ import java.util.Locale; * The base abstract Tab fragment */ public abstract class TabFragment extends Fragment { - + private static final String TAG = TabFragment.class.getSimpleName(); protected PickerViewModel mPickerViewModel; protected Selection mSelection; protected ImageLoader mImageLoader; @@ -80,6 +83,8 @@ public abstract class TabFragment extends Fragment { private boolean mIsAccessibilityEnabled; private Button mAddButton; + + private Button mViewSelectedButton; private View mBottomBar; private Animation mSlideUpAnimation; private Animation mSlideDownAnimation; @@ -98,6 +103,8 @@ public abstract class TabFragment extends Fragment { private int mRecyclerViewBottomPadding; + private RecyclerView.OnScrollListener mOnScrollListenerForMultiProfileButton; + private final MutableLiveData<Boolean> mIsBottomBarVisible = new MutableLiveData<>(false); private final MutableLiveData<Boolean> mIsProfileButtonVisible = new MutableLiveData<>(false); @@ -113,11 +120,13 @@ public abstract class TabFragment extends Fragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - final Context context = getContext(); + final Context context = requireContext(); + final FragmentActivity activity = requireActivity(); + mImageLoader = new ImageLoader(context); mRecyclerView = view.findViewById(R.id.picker_tab_recyclerview); mRecyclerView.setHasFixedSize(true); - final ViewModelProvider viewModelProvider = new ViewModelProvider(requireActivity()); + final ViewModelProvider viewModelProvider = new ViewModelProvider(activity); mPickerViewModel = viewModelProvider.get(PickerViewModel.class); mSelection = mPickerViewModel.getSelection(); mRecyclerViewBottomPadding = getResources().getDimensionPixelSize( @@ -144,58 +153,89 @@ public abstract class TabFragment extends Fragment { mButtonIconAndTextColor = ta.getColor(/* index */ 1, /* defValue */ -1); ta.recycle(); - mProfileButton = getActivity().findViewById(R.id.profile_button); + mProfileButton = activity.findViewById(R.id.profile_button); mUserIdManager = mPickerViewModel.getUserIdManager(); final boolean canSelectMultiple = mSelection.canSelectMultiple(); if (canSelectMultiple) { - mAddButton = getActivity().findViewById(R.id.button_add); + mAddButton = activity.findViewById(R.id.button_add); + mViewSelectedButton = activity.findViewById(R.id.button_view_selected); mAddButton.setOnClickListener(v -> { - ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf(); + try { + requirePickerActivity().setResultAndFinishSelf(); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } }); - - final Button viewSelectedButton = getActivity().findViewById(R.id.button_view_selected); // Transition to PreviewFragment on clicking "View Selected". - viewSelectedButton.setOnClickListener(v -> { + mViewSelectedButton.setOnClickListener(v -> { + // Load items for preview that are pre granted but not yet loaded for UI. This is an + // async call. Until the items are loaded, we can still preview already available + // items + mPickerViewModel.getRemainingPreGrantedItems(); mSelection.prepareSelectedItemsForPreviewAll(); - PreviewFragment.show(getActivity().getSupportFragmentManager(), - PreviewFragment.getArgsForPreviewOnViewSelected()); + + int selectedItemCount = mSelection.getSelectedItemCount().getValue(); + mPickerViewModel.logPreviewAllSelected(selectedItemCount); + + try { + PreviewFragment.show(requireActivity().getSupportFragmentManager(), + PreviewFragment.getArgsForPreviewOnViewSelected()); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } }); - mBottomBar = getActivity().findViewById(R.id.picker_bottom_bar); - mSlideUpAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_up); - mSlideDownAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_down); + mBottomBar = activity.findViewById(R.id.picker_bottom_bar); + // consume the event so that it doesn't get passed through to the next view b/287661737 + mBottomBar.setOnClickListener(v -> {}); + mSlideUpAnimation = AnimationUtils.loadAnimation(context, R.anim.slide_up); + mSlideDownAnimation = AnimationUtils.loadAnimation(context, R.anim.slide_down); mSelection.getSelectedItemCount().observe(this, selectedItemListSize -> { - updateProfileButtonVisibility(); - updateVisibilityAndAnimateBottomBar(selectedItemListSize); + // Fetch activity or context again instead of capturing existing variable in lambdas + // to avoid memory leaks. + try { + updateProfileButtonVisibility(); + updateVisibilityAndAnimateBottomBar(requireContext(), selectedItemListSize); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } }); } - // Initial setup - setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles()); - // Observe for cross profile access changes. final LiveData<Boolean> crossProfileAllowed = mUserIdManager.getCrossProfileAllowed(); if (crossProfileAllowed != null) { crossProfileAllowed.observe(this, isCrossProfileAllowed -> { setUpProfileButton(); + if (Boolean.TRUE.equals(mIsProfileButtonVisible.getValue())) { + if (isCrossProfileAllowed) { + mPickerViewModel.logProfileSwitchButtonEnabled(); + } else { + mPickerViewModel.logProfileSwitchButtonDisabled(); + } + } }); } - // Observe for multi-user changes. - final LiveData<Boolean> isMultiUserProfiles = mUserIdManager.getIsMultiUserProfiles(); - if (isMultiUserProfiles != null) { - isMultiUserProfiles.observe(this, this::setUpProfileButtonWithListeners); - } final AccessibilityManager accessibilityManager = context.getSystemService(AccessibilityManager.class); mIsAccessibilityEnabled = accessibilityManager.isEnabled(); accessibilityManager.addAccessibilityStateChangeListener(enabled -> { mIsAccessibilityEnabled = enabled; - updateProfileButtonVisibility(); + setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles()); }); + + // Observe for multi-user changes. + final LiveData<Boolean> isMultiUserProfiles = mUserIdManager.getIsMultiUserProfiles(); + if (isMultiUserProfiles != null) { + isMultiUserProfiles.observe(this, this::setUpProfileButtonWithListeners); + } + + // Initial setup + setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles()); } private void updateRecyclerViewBottomPadding() { @@ -209,29 +249,49 @@ public abstract class TabFragment extends Fragment { mRecyclerView.setPadding(0, 0, 0, recyclerViewBottomPadding); } - private void updateVisibilityAndAnimateBottomBar(int selectedItemListSize) { + private void updateVisibilityAndAnimateBottomBar(@NonNull Context context, + int selectedItemListSize) { if (!mSelection.canSelectMultiple()) { return; } - if (selectedItemListSize == 0) { - if (mBottomBar.getVisibility() == View.VISIBLE) { - mBottomBar.setVisibility(View.GONE); - mBottomBar.startAnimation(mSlideDownAnimation); + if (mPickerViewModel.isManagedSelectionEnabled()) { + animateAndShowBottomBar(context, selectedItemListSize); + if (selectedItemListSize == 0) { + mViewSelectedButton.setVisibility(View.GONE); + // Update the add button to show "Allow none". + mAddButton.setText(R.string.picker_add_button_allow_none_option); } } else { - if (mBottomBar.getVisibility() == View.GONE) { - mBottomBar.setVisibility(View.VISIBLE); - mBottomBar.startAnimation(mSlideUpAnimation); + if (selectedItemListSize == 0) { + animateAndHideBottomBar(); + } else { + animateAndShowBottomBar(context, selectedItemListSize); } - mAddButton.setText(generateAddButtonString(getContext(), selectedItemListSize)); } - mIsBottomBarVisible.setValue(selectedItemListSize > 0); + mIsBottomBarVisible.setValue( + mPickerViewModel.isManagedSelectionEnabled() || selectedItemListSize > 0); + } + + private void animateAndShowBottomBar(Context context, int selectedItemListSize) { + if (mBottomBar.getVisibility() == View.GONE) { + mBottomBar.setVisibility(View.VISIBLE); + mBottomBar.startAnimation(mSlideUpAnimation); + } + mViewSelectedButton.setVisibility(View.VISIBLE); + mAddButton.setText(generateAddButtonString(context, selectedItemListSize)); + } + + private void animateAndHideBottomBar() { + if (mBottomBar.getVisibility() == View.VISIBLE) { + mBottomBar.setVisibility(View.GONE); + mBottomBar.startAnimation(mSlideDownAnimation); + } } private void setUpListenersForProfileButton() { mProfileButton.setOnClickListener(v -> onClickProfileButton()); - mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + mOnScrollListenerForMultiProfileButton = new RecyclerView.OnScrollListener() { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); @@ -248,7 +308,8 @@ public abstract class TabFragment extends Fragment { updateProfileButtonVisibility(); } } - }); + }; + mRecyclerView.addOnScrollListener(mOnScrollListenerForMultiProfileButton); } @Override @@ -260,10 +321,11 @@ public abstract class TabFragment extends Fragment { } private void setUpProfileButtonWithListeners(boolean isMultiUserProfile) { + if (mOnScrollListenerForMultiProfileButton != null) { + mRecyclerView.removeOnScrollListener(mOnScrollListenerForMultiProfileButton); + } if (isMultiUserProfile) { setUpListenersForProfileButton(); - } else { - mRecyclerView.clearOnScrollListeners(); } setUpProfileButton(); } @@ -287,8 +349,14 @@ public abstract class TabFragment extends Fragment { } private void onClickProfileButton() { + mPickerViewModel.logProfileSwitchButtonClick(); + if (!mUserIdManager.isCrossProfileAllowed()) { - ProfileDialogFragment.show(getActivity().getSupportFragmentManager()); + try { + ProfileDialogFragment.show(requireActivity().getSupportFragmentManager()); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } } else { changeProfile(); } @@ -308,60 +376,79 @@ public abstract class TabFragment extends Fragment { updateProfileButtonContent(mUserIdManager.isManagedUserSelected()); - mPickerViewModel.onUserSwitchedProfile(); + mPickerViewModel.onSwitchedProfile(); } private void updateProfileButtonContent(boolean isManagedUserSelected) { final Drawable icon; final String text; + final Context context; + try { + context = requireContext(); + } catch (RuntimeException e) { + Log.e(TAG, "Could not update profile button content because the fragment is not" + + " attached."); + return; + } + if (isManagedUserSelected) { - icon = getContext().getDrawable(R.drawable.ic_personal_mode); - text = getSwitchToPersonalMessage(); + icon = context.getDrawable(R.drawable.ic_personal_mode); + text = getSwitchToPersonalMessage(context); } else { - icon = getWorkProfileIcon(); - text = getSwitchToWorkMessage(); + icon = getWorkProfileIcon(context); + text = getSwitchToWorkMessage(context); } mProfileButton.setIcon(icon); mProfileButton.setText(text); } - private String getSwitchToPersonalMessage() { + private String getSwitchToPersonalMessage(@NonNull Context context) { if (SdkLevel.isAtLeastT()) { return getUpdatedEnterpriseString( - SWITCH_TO_PERSONAL_MESSAGE, R.string.picker_personal_profile); + context, SWITCH_TO_PERSONAL_MESSAGE, R.string.picker_personal_profile); } else { - return getContext().getString(R.string.picker_personal_profile); + return context.getString(R.string.picker_personal_profile); } } - private String getSwitchToWorkMessage() { + private String getSwitchToWorkMessage(@NonNull Context context) { if (SdkLevel.isAtLeastT()) { return getUpdatedEnterpriseString( - SWITCH_TO_WORK_MESSAGE, R.string.picker_work_profile); + context, SWITCH_TO_WORK_MESSAGE, R.string.picker_work_profile); } else { - return getContext().getString(R.string.picker_work_profile); + return context.getString(R.string.picker_work_profile); } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private String getUpdatedEnterpriseString(String updatableStringId, int defaultStringId) { - final DevicePolicyManager dpm = getContext().getSystemService(DevicePolicyManager.class); + private String getUpdatedEnterpriseString(@NonNull Context context, + @NonNull String updatableStringId, + int defaultStringId) { + final DevicePolicyManager dpm = context.getSystemService(DevicePolicyManager.class); return dpm.getResources().getString(updatableStringId, () -> getString(defaultStringId)); } - private Drawable getWorkProfileIcon() { + private Drawable getWorkProfileIcon(@NonNull Context context) { if (SdkLevel.isAtLeastT()) { - return getUpdatedWorkProfileIcon(); + return getUpdatedWorkProfileIcon(context); } else { - return getContext().getDrawable(R.drawable.ic_work_outline); + return context.getDrawable(R.drawable.ic_work_outline); } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private Drawable getUpdatedWorkProfileIcon() { - DevicePolicyManager dpm = getContext().getSystemService(DevicePolicyManager.class); - return dpm.getResources().getDrawable(WORK_PROFILE_ICON, OUTLINE, () -> - getContext().getDrawable(R.drawable.ic_work_outline)); + private Drawable getUpdatedWorkProfileIcon(@NonNull Context context) { + DevicePolicyManager dpm = context.getSystemService(DevicePolicyManager.class); + return dpm.getResources().getDrawable(WORK_PROFILE_ICON, OUTLINE, () -> { + // Fetch activity or context again instead of capturing existing variable in + // lambdas to avoid memory leaks. + try { + return requireContext().getDrawable(R.drawable.ic_work_outline); + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + return null; + } + }); } private void updateProfileButtonColor(boolean isDisabled) { @@ -397,6 +484,8 @@ public abstract class TabFragment extends Fragment { /** * If we show the {@link #mEmptyView}, hide the {@link #mRecyclerView}. If we don't hide the * {@link #mEmptyView}, show the {@link #mRecyclerView} + * when user switches the profile ,till the time when updated profile data is loading, + * on the UI we hide {@link #mEmptyView} and show Empty {@link #mRecyclerView} */ protected void updateVisibilityForEmptyView(boolean shouldShowEmptyView) { mEmptyView.setVisibility(shouldShowEmptyView ? View.VISIBLE : View.GONE); @@ -420,13 +509,18 @@ public abstract class TabFragment extends Fragment { return TextUtils.expandTemplate(template, sizeString).toString(); } - protected final PhotoPickerActivity getPickerActivity() { - return (PhotoPickerActivity) getActivity(); + /** + * Returns {@link PhotoPickerActivity} if the fragment is attached to one. Otherwise, throws an + * {@link IllegalStateException}. + */ + protected final PhotoPickerActivity requirePickerActivity() throws IllegalStateException { + return (PhotoPickerActivity) requireActivity(); } - protected final void setLayoutManager(@NonNull TabAdapter adapter, int spanCount) { + protected final void setLayoutManager(@NonNull Context context, + @NonNull TabAdapter adapter, int spanCount) { final GridLayoutManager layoutManager = - new GridLayoutManager(getContext(), spanCount); + new GridLayoutManager(context, spanCount); final GridLayoutManager.SpanSizeLookup lookup = new GridLayoutManager.SpanSizeLookup() { @Override public int getSpanSize(int position) { @@ -447,17 +541,28 @@ public abstract class TabFragment extends Fragment { private abstract class OnBannerEventListener implements TabAdapter.OnBannerEventListener { @Override public void onActionButtonClick() { + mPickerViewModel.logBannerActionButtonClicked(); dismissBanner(); - getPickerActivity().startSettingsActivity(); + launchCloudProviderSettings(); } @Override public void onDismissButtonClick() { + mPickerViewModel.logBannerDismissed(); dismissBanner(); } @Override - public void onBannerAdded() { + public void onBannerClick() { + mPickerViewModel.logBannerClicked(); + dismissBanner(); + launchCloudProviderSettings(); + } + + @Override + public void onBannerAdded(@NonNull String name) { + mPickerViewModel.logBannerAdded(name); + // Should scroll to the banner only if the first completely visible item is the one // just below it. The possible adapter item positions of such an item are 0 and 1. // During onViewCreated, before restoring the state, the first visible item position @@ -477,6 +582,21 @@ public abstract class TabFragment extends Fragment { } abstract void dismissBanner(); + + private void launchCloudProviderSettings() { + final Intent accountChangeIntent = + mPickerViewModel.getChooseCloudMediaAccountActivityIntent(); + + try { + if (accountChangeIntent != null) { + requirePickerActivity().startActivity(accountChangeIntent); + } else { + requirePickerActivity().startSettingsActivity(); + } + } catch (RuntimeException e) { + Log.e(TAG, "Fragment is likely not attached to an activity. ", e); + } + } } protected final OnBannerEventListener mOnChooseAppBannerEventListener = @@ -493,6 +613,11 @@ public abstract class TabFragment extends Fragment { void dismissBanner() { mPickerViewModel.onUserDismissedCloudMediaAvailableBanner(); } + + @Override + public boolean shouldShowActionButton() { + return mPickerViewModel.getChooseCloudMediaAccountActivityIntent() != null; + } }; protected final OnBannerEventListener mOnAccountUpdatedBannerEventListener = @@ -509,5 +634,10 @@ public abstract class TabFragment extends Fragment { void dismissBanner() { mPickerViewModel.onUserDismissedChooseAccountBanner(); } + + @Override + public boolean shouldShowActionButton() { + return mPickerViewModel.getChooseCloudMediaAccountActivityIntent() != null; + } }; } diff --git a/src/com/android/providers/media/photopicker/ui/ViewPager2Wrapper.java b/src/com/android/providers/media/photopicker/ui/ViewPager2Wrapper.java index 563f77785..f55376d4e 100644 --- a/src/com/android/providers/media/photopicker/ui/ViewPager2Wrapper.java +++ b/src/com/android/providers/media/photopicker/ui/ViewPager2Wrapper.java @@ -19,6 +19,7 @@ package com.android.providers.media.photopicker.ui; import android.content.Context; import android.view.View; +import androidx.annotation.NonNull; import androidx.viewpager2.widget.CompositePageTransformer; import androidx.viewpager2.widget.MarginPageTransformer; import androidx.viewpager2.widget.ViewPager2; @@ -42,12 +43,15 @@ class ViewPager2Wrapper { private final PreviewAdapter mAdapter; private final List<ViewPager2.OnPageChangeCallback> mOnPageChangeCallbacks = new ArrayList<>(); - ViewPager2Wrapper(ViewPager2 viewPager, List<Item> selectedItems, MuteStatus muteStatus) { + ViewPager2Wrapper(ViewPager2 viewPager, List<Item> selectedItems, MuteStatus muteStatus, + @NonNull PreviewAdapter.OnCreateSurfaceController onCreateSurfaceController, + @NonNull PreviewAdapter.OnVideoPreviewClickListener onVideoPreviewClickListener) { mViewPager = viewPager; final Context context = mViewPager.getContext(); - mAdapter = new PreviewAdapter(context, muteStatus); + mAdapter = new PreviewAdapter(context, muteStatus, onCreateSurfaceController, + onVideoPreviewClickListener); mAdapter.updateItemList(selectedItems); mViewPager.setAdapter(mAdapter); @@ -58,6 +62,10 @@ class ViewPager2Wrapper { mViewPager.setPageTransformer(compositePageTransformer); } + void updateList(List<Item> selectedItems) { + mAdapter.updateItemList(selectedItems); + } + /** * Registers given {@link ViewPager2.OnPageChangeCallback} to the {@link ViewPager2}. This class * also takes care of unregistering the callback onDestroy() diff --git a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java index b07fc9b61..dae4e5b4d 100644 --- a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java +++ b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java @@ -41,9 +41,12 @@ import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; +import androidx.annotation.NonNull; + import com.android.providers.media.photopicker.RemoteVideoPreviewProvider; import com.android.providers.media.photopicker.data.MuteStatus; import com.android.providers.media.photopicker.data.model.Item; +import com.android.providers.media.photopicker.ui.PreviewAdapter.OnCreateSurfaceController; import com.android.providers.media.photopicker.ui.PreviewVideoHolder; import java.util.Map; @@ -72,13 +75,16 @@ public final class RemotePreviewHandler { private final ItemPreviewState mCurrentPreviewState = new ItemPreviewState(); private final PlayerControlsVisibilityStatus mPlayerControlsVisibilityStatus = new PlayerControlsVisibilityStatus(); + private final OnCreateSurfaceController mOnCreateSurfaceController; private boolean mIsInBackground = false; private int mSurfaceCounter = 0; - public RemotePreviewHandler(Context context, MuteStatus muteStatus) { + public RemotePreviewHandler(Context context, MuteStatus muteStatus, + @NonNull OnCreateSurfaceController onCreateSurfaceController) { mContext = context; mMuteStatus = muteStatus; + mOnCreateSurfaceController = onCreateSurfaceController; } /** @@ -200,9 +206,11 @@ public final class RemotePreviewHandler { SurfaceControllerProxy controller = null; try { + mOnCreateSurfaceController.logStart(authority); controller = createController(authority, localControllerFallback); if (controller != null) { mControllers.put(authority, controller); + mOnCreateSurfaceController.logEnd(authority); } } catch (RuntimeException e) { Log.e(TAG, "Could not create SurfaceController.", e); diff --git a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java index 0fa806892..cac25f587 100644 --- a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java +++ b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java @@ -89,6 +89,7 @@ final class RemotePreviewSession { private final View.OnClickListener mMuteButtonClickListener = new View.OnClickListener() { @Override public void onClick(View v) { + mPreviewVideoHolder.logMuteButtonClick(); boolean newMutedValue = !mMuteStatus.isVolumeMuted(); mMuteStatus.setVolumeMuted(newMutedValue); handleAudioFocusAndInitVolumeState(); @@ -267,6 +268,12 @@ final class RemotePreviewSession { case PLAYBACK_STATE_BUFFERING: mPreviewVideoHolder.getCircularProgressIndicator().setVisibility(View.VISIBLE); return; + case PLAYBACK_STATE_COMPLETED: + // TODO(b/296543163): Investigate CloudMediaProviderContract for future OEM + // implementers. Should the provider be expected to loop the video themselves + // instead of ending the playback state? + requestPlayMedia(); + return; default: } } @@ -374,7 +381,8 @@ final class RemotePreviewSession { // media size, then we hide the thumbnail view. mPreviewVideoHolder.getPlayerContainer().setVisibility(View.INVISIBLE); mPreviewVideoHolder.getThumbnailView().setVisibility(View.VISIBLE); - mPreviewVideoHolder.getPlayerControlsRoot().setVisibility(View.GONE); + updatePlayerControlsVisibilityState( + mPlayerControlsVisibilityStatus.shouldShowPlayerControls()); mPreviewVideoHolder.getCircularProgressIndicator().setVisibility(View.GONE); updatePlayPauseButtonState(false /* isPlaying */); @@ -449,7 +457,11 @@ final class RemotePreviewSession { mIsAccessibilityEnabled = enabled; mPreviewVideoHolder.getPlayerContainer().setOnClickListener( mIsAccessibilityEnabled ? null : mPlayerContainerClickListener); - updatePlayerControlsVisibilityState(mIsAccessibilityEnabled); + if (mIsAccessibilityEnabled) { + updatePlayerControlsVisibilityState(/* visible= */ true); + } else { + hidePlayerControlsWithDelay(); + } } private void updatePlayPauseButtonState(boolean isPlaying) { diff --git a/src/com/android/providers/media/photopicker/ui/settings/CloudMediaProviderAccount.java b/src/com/android/providers/media/photopicker/ui/settings/CloudMediaProviderAccount.java deleted file mode 100644 index fc2d332f6..000000000 --- a/src/com/android/providers/media/photopicker/ui/settings/CloudMediaProviderAccount.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2023 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.settings; - -import static java.util.Objects.requireNonNull; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/* POJO for encapsulating cloud provider authority and it's linked account name. */ -class CloudMediaProviderAccount { - @NonNull - private final String mCloudProviderAuthority; - @Nullable - private final String mCloudProviderAccountName; - - CloudMediaProviderAccount( - @NonNull String cloudProviderAuthority, - @Nullable String cloudProviderAccountName) { - mCloudProviderAuthority = requireNonNull(cloudProviderAuthority); - mCloudProviderAccountName = cloudProviderAccountName; - } - - @NonNull - String getCloudProviderAuthority() { - return mCloudProviderAuthority; - } - - @Nullable - String getCloudProviderAccountName() { - return mCloudProviderAccountName; - } -} diff --git a/src/com/android/providers/media/photopicker/ui/settings/CloudProviderMediaCollectionInfo.java b/src/com/android/providers/media/photopicker/ui/settings/CloudProviderMediaCollectionInfo.java new file mode 100644 index 000000000..7c6d54685 --- /dev/null +++ b/src/com/android/providers/media/photopicker/ui/settings/CloudProviderMediaCollectionInfo.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2023 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.settings; + +import static java.util.Objects.requireNonNull; + +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/* POJO for encapsulating the cloud provider authority and it's media collection info. */ +class CloudProviderMediaCollectionInfo { + @NonNull + private final String mAuthority; + @Nullable + private final String mAccountName; + @Nullable + private final Intent mAccountConfigurationIntent; + + CloudProviderMediaCollectionInfo(@NonNull String authority) { + mAuthority = requireNonNull(authority); + mAccountName = null; + mAccountConfigurationIntent = null; + } + + CloudProviderMediaCollectionInfo(@NonNull String authority, @Nullable String accountName, + @Nullable Intent accountConfigurationIntent) { + mAuthority = requireNonNull(authority); + mAccountName = accountName; + mAccountConfigurationIntent = accountConfigurationIntent; + } + + @NonNull + String getAuthority() { + return mAuthority; + } + + @Nullable + String getAccountName() { + return mAccountName; + } + + @Nullable + Intent getAccountConfigurationIntent() { + return mAccountConfigurationIntent; + } +} diff --git a/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaSelectFragment.java b/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaSelectFragment.java index c8d1cc7d4..f08bd7521 100644 --- a/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaSelectFragment.java +++ b/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaSelectFragment.java @@ -21,6 +21,7 @@ import static com.android.providers.media.MediaApplication.getConfigStore; import static java.util.Objects.requireNonNull; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; import android.text.TextUtils; @@ -67,7 +68,7 @@ public class SettingsCloudMediaSelectFragment extends PreferenceFragmentCompat public void onResume() { super.onResume(); - mSettingsCloudMediaViewModel.loadAccountNameAsync(); + mSettingsCloudMediaViewModel.loadMediaCollectionInfoAsync(); } @UiThread @@ -93,7 +94,7 @@ public class SettingsCloudMediaSelectFragment extends PreferenceFragmentCompat super.addPreferencesFromResource(R.xml.pref_screen_picker_settings); mSettingsCloudMediaViewModel.loadData(getConfigStore()); - observeAccountNameChanges(); + observeMediaCollectionInfoChanges(); refreshUI(); } @@ -111,23 +112,34 @@ public class SettingsCloudMediaSelectFragment extends PreferenceFragmentCompat updateSelectedRadioButton(); } - private void observeAccountNameChanges() { - mSettingsCloudMediaViewModel.getCurrentProviderAccount() - .observe(this, accountDetails -> { - // Only update current account name on the UI if cloud provider linked to the - // account name matches the current provider. - if (accountDetails != null - && accountDetails.getCloudProviderAuthority() - .equals(mSettingsCloudMediaViewModel.getSelectedProviderAuthority())) { - final Preference selectedPref = findPreference( - mSettingsCloudMediaViewModel.getSelectedPreferenceKey()); - // TODO(b/262002538): {@code selectedPref} could be null if the selected - // cloud provider is not in the allowed list. This is not something a - // typical user will encounter. - if (selectedPref != null) { - selectedPref.setSummary(accountDetails.getCloudProviderAccountName()); - } + private void observeMediaCollectionInfoChanges() { + mSettingsCloudMediaViewModel.getCurrentProviderMediaCollectionInfo().observe(this, + providerMediaCollectionInfo -> { + // Only update the UI preference if the cloud provider linked to the media + // collection info matches the current provider. + if (providerMediaCollectionInfo == null + || !TextUtils.equals(providerMediaCollectionInfo.getAuthority(), + mSettingsCloudMediaViewModel.getSelectedProviderAuthority())) { + return; } + + final SelectorWithWidgetPreference selectedPref = + findPreference(mSettingsCloudMediaViewModel.getSelectedPreferenceKey()); + + // TODO(b/262002538): {@code selectedPref} could be null if the selected + // cloud provider is not in the allowed list. This is not something a + // typical user will encounter. + if (selectedPref == null) { + return; + } + + selectedPref.setSummary(providerMediaCollectionInfo.getAccountName()); + + final Intent accountConfigurationIntent = + providerMediaCollectionInfo.getAccountConfigurationIntent(); + selectedPref.setExtraWidgetOnClickListener( + accountConfigurationIntent == null ? null : v -> + requireActivity().startActivity(accountConfigurationIntent)); }); } @@ -137,19 +149,19 @@ public class SettingsCloudMediaSelectFragment extends PreferenceFragmentCompat mSettingsCloudMediaViewModel.getSelectedPreferenceKey(); for (CloudMediaProviderOption providerOption : mSettingsCloudMediaViewModel.getProviderOptions()) { - final Preference pref = findPreference(providerOption.getKey()); - if (pref instanceof SelectorWithWidgetPreference) { - final SelectorWithWidgetPreference providerPref = - (SelectorWithWidgetPreference) pref; - - final boolean newSelectionState = - TextUtils.equals(providerPref.getKey(), selectedPreferenceKey); - providerPref.setChecked(newSelectionState); - - providerPref.setSummary(null); - if (newSelectionState) { - mSettingsCloudMediaViewModel.loadAccountNameAsync(); - } + final SelectorWithWidgetPreference preference = findPreference(providerOption.getKey()); + if (preference == null) { + continue; + } + + final boolean isSelected = TextUtils.equals(preference.getKey(), selectedPreferenceKey); + preference.setChecked(isSelected); + + preference.setSummary(null); + preference.setExtraWidgetOnClickListener(null); + + if (isSelected) { + mSettingsCloudMediaViewModel.loadMediaCollectionInfoAsync(); } } } diff --git a/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModel.java b/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModel.java index 346eed309..e31697047 100644 --- a/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModel.java +++ b/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModel.java @@ -20,17 +20,21 @@ import static android.provider.MediaStore.AUTHORITY; import static com.android.providers.media.photopicker.util.CloudProviderUtils.fetchProviderAuthority; import static com.android.providers.media.photopicker.util.CloudProviderUtils.getAvailableCloudProviders; -import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaAccountName; +import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaCollectionInfo; import static com.android.providers.media.photopicker.util.CloudProviderUtils.persistSelectedProvider; import static java.util.Objects.requireNonNull; import android.content.ContentProviderClient; +import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; +import android.os.Bundle; import android.os.Looper; import android.os.UserHandle; +import android.provider.CloudMediaProviderContract; import android.util.Log; import androidx.annotation.NonNull; @@ -56,11 +60,13 @@ import java.util.List; public class SettingsCloudMediaViewModel extends ViewModel { static final String NONE_PREF_KEY = "none"; private static final String TAG = "SettingsFragVM"; + private static final long GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS = 10000L; @NonNull private final Context mContext; @NonNull - private final MutableLiveData<CloudMediaProviderAccount> mCurrentProviderAccount; + private final MutableLiveData<CloudProviderMediaCollectionInfo> + mCurrentProviderMediaCollectionInfo; @NonNull private final List<CloudMediaProviderOption> mProviderOptions; @NonNull @@ -77,7 +83,7 @@ public class SettingsCloudMediaViewModel extends ViewModel { mUserId = requireNonNull(userId); mProviderOptions = new ArrayList<>(); mSelectedProviderAuthority = null; - mCurrentProviderAccount = new MutableLiveData<CloudMediaProviderAccount>(); + mCurrentProviderMediaCollectionInfo = new MutableLiveData<>(); } @NonNull @@ -91,11 +97,11 @@ public class SettingsCloudMediaViewModel extends ViewModel { } @NonNull - LiveData<CloudMediaProviderAccount> getCurrentProviderAccount() { - return mCurrentProviderAccount; + LiveData<CloudProviderMediaCollectionInfo> getCurrentProviderMediaCollectionInfo() { + return mCurrentProviderMediaCollectionInfo; } - @Nullable + @NonNull String getSelectedPreferenceKey() { return getPreferenceKey(mSelectedProviderAuthority); } @@ -140,7 +146,7 @@ public class SettingsCloudMediaViewModel extends ViewModel { ? null : preferenceKey; } - @Nullable + @NonNull private String getPreferenceKey(@Nullable String providerAuthority) { return providerAuthority == null ? SettingsCloudMediaViewModel.NONE_PREF_KEY : providerAuthority; @@ -171,38 +177,50 @@ public class SettingsCloudMediaViewModel extends ViewModel { } @UiThread - void loadAccountNameAsync() { + void loadMediaCollectionInfoAsync() { if (!Looper.getMainLooper().isCurrentThread()) { - // This method should only be run from the UI thread so that fetch account name + // This method should only be run from the UI thread so that fetch media collection info // requests are executed serially. - Log.d(TAG, "loadAccountNameAsync method needs to be called from the UI thread"); + Log.w(TAG, "loadMediaCollectionInfoAsync method needs to be called from the UI thread"); return; } final String providerAuthority = getSelectedProviderAuthority(); // Foreground thread internally uses a queue to execute each request in a serialized manner. ForegroundThread.getExecutor().execute(() -> { - mCurrentProviderAccount.postValue( - fetchAccountFromProvider(providerAuthority)); + mCurrentProviderMediaCollectionInfo.postValue( + fetchMediaCollectionInfoFromProvider(providerAuthority)); }); } @Nullable - private CloudMediaProviderAccount fetchAccountFromProvider( + private CloudProviderMediaCollectionInfo fetchMediaCollectionInfoFromProvider( @Nullable String currentProviderAuthority) { + // If the selected cloud provider preference is "None", the media collection info is not + // applicable. if (currentProviderAuthority == null) { - // If the selected cloud provider preference is "None", account name is not applicable. return null; - } else { - try { - final String accountName = getCloudMediaAccountName( - mUserId.getContentResolver(mContext), currentProviderAuthority); - return new CloudMediaProviderAccount(currentProviderAuthority, accountName); - } catch (Exception e) { - Log.w(TAG, "Failed to fetch account name from the cloud media provider.", e); - return null; - } } + + Bundle cloudMediaCollectionInfo = null; + try { + final ContentResolver currentUserContentResolver = mUserId.getContentResolver(mContext); + cloudMediaCollectionInfo = getCloudMediaCollectionInfo(currentUserContentResolver, + currentProviderAuthority, GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS); + } catch (Exception e) { + Log.w(TAG, "Failed to fetch media collection info from the cloud media provider.", e); + } + + if (cloudMediaCollectionInfo == null) { + return new CloudProviderMediaCollectionInfo(currentProviderAuthority); + } + + final String accountName = cloudMediaCollectionInfo.getString( + CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_NAME); + final Intent cloudProviderSettingsActivityIntent = cloudMediaCollectionInfo.getParcelable( + CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT); + return new CloudProviderMediaCollectionInfo(currentProviderAuthority, accountName, + cloudProviderSettingsActivityIntent); } @NonNull diff --git a/src/com/android/providers/media/photopicker/ui/settings/SettingsProfileSelectFragment.java b/src/com/android/providers/media/photopicker/ui/settings/SettingsProfileSelectFragment.java index d49068437..fb31c2df2 100644 --- a/src/com/android/providers/media/photopicker/ui/settings/SettingsProfileSelectFragment.java +++ b/src/com/android/providers/media/photopicker/ui/settings/SettingsProfileSelectFragment.java @@ -29,7 +29,7 @@ import androidx.lifecycle.ViewModelProvider; import com.android.providers.media.photopicker.data.UserIdManager; import com.android.settingslib.widget.ProfileSelectFragment; -import com.android.settingslib.widget.R; +import com.android.settingslib.widget.profileselector.R; import com.google.android.material.tabs.TabLayout; diff --git a/src/com/android/providers/media/photopicker/util/CategoryOrganiserUtils.java b/src/com/android/providers/media/photopicker/util/CategoryOrganiserUtils.java new file mode 100644 index 000000000..8857ce625 --- /dev/null +++ b/src/com/android/providers/media/photopicker/util/CategoryOrganiserUtils.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 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.util; + +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS; + +import com.android.providers.media.photopicker.data.model.Category; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Reorders categories per requirements. + */ +public class CategoryOrganiserUtils { + static final int DEFAULT_PRIORITY = 100; + static Map<String, Integer> sCategoryPriorityMapping; + + /** + * Rearranges categoryList in the required order: Favourites, camera, videos, + * screenshots, downloads, ... cloud albums ordered by last modified time stamp. + */ + public static void getReorganisedCategoryList(List<Category> categoryList) { + // Items having the same priority will not be modified in order. + categoryList.sort(new CategoryComparator()); + } + + private static void populateCategoryPriorityMapping() { + + // DO NOT ALTER THIS ORDER. + // These priorities decide the order in which the categories will be displayed on UI. + sCategoryPriorityMapping = new HashMap<String, Integer>() { + { + put(ALBUM_ID_FAVORITES, 0); + put(ALBUM_ID_CAMERA, 1); + put(ALBUM_ID_VIDEOS, 2); + put(ALBUM_ID_SCREENSHOTS, 3); + put(ALBUM_ID_DOWNLOADS, 4); + } + }; + } + + private static int getPriority(Category category) { + if (sCategoryPriorityMapping == null) { + populateCategoryPriorityMapping(); + } + if (sCategoryPriorityMapping.containsKey(category.getId())) { + return sCategoryPriorityMapping.get(category.getId()); + } + return DEFAULT_PRIORITY; + } + + static class CategoryComparator implements java.util.Comparator<Category> { + @Override + public int compare(Category category1, Category category2) { + return getPriority(category1) - getPriority(category2); + } + } +} diff --git a/src/com/android/providers/media/photopicker/util/CloudProviderUtils.java b/src/com/android/providers/media/photopicker/util/CloudProviderUtils.java index b2ba05764..57e6f75fd 100644 --- a/src/com/android/providers/media/photopicker/util/CloudProviderUtils.java +++ b/src/com/android/providers/media/photopicker/util/CloudProviderUtils.java @@ -18,14 +18,21 @@ package com.android.providers.media.photopicker.util; import static android.provider.CloudMediaProviderContract.MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION; import static android.provider.CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO; -import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_NAME; +import static android.provider.MediaStore.EXTRA_ALBUM_AUTHORITY; +import static android.provider.MediaStore.EXTRA_ALBUM_ID; import static android.provider.MediaStore.EXTRA_CLOUD_PROVIDER; +import static android.provider.MediaStore.EXTRA_LOCAL_ONLY; import static android.provider.MediaStore.GET_CLOUD_PROVIDER_CALL; import static android.provider.MediaStore.GET_CLOUD_PROVIDER_RESULT; +import static android.provider.MediaStore.PICKER_MEDIA_INIT_CALL; import static android.provider.MediaStore.SET_CLOUD_PROVIDER_CALL; +import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri; + import static java.util.Collections.emptyList; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import android.annotation.DurationMillisLong; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Context; @@ -51,6 +58,9 @@ import com.android.providers.media.photopicker.data.model.UserId; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; /** * Utility methods for retrieving available and/or allowlisted Cloud Providers. @@ -191,6 +201,24 @@ public class CloudProviderUtils { } /** + * Send data init call. + */ + public static boolean sendInitPhotoPickerDataNotification( + @NonNull ContentProviderClient client, + @Nullable String albumId, + @Nullable String albumAuthority, + boolean initLocalOnlyData) throws RemoteException { + final Bundle input = new Bundle(); + input.putString(EXTRA_ALBUM_ID, albumId); + input.putString(EXTRA_ALBUM_AUTHORITY, albumAuthority); + input.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyData); + Log.i(TAG, "Sending media init query for extras: " + input); + + client.call(PICKER_MEDIA_INIT_CALL, /* arg */ null, /* extras */ input); + return true; + } + + /** * @return the label for the {@link ProviderInfo} with {@code authority} for the given * {@link UserHandle}. */ @@ -226,23 +254,24 @@ public class CloudProviderUtils { } /** - * @return the current cloud media account name for the {@link CloudMediaProvider} with the + * @param resolver {@link ContentResolver} for the related user + * @param cloudMediaProviderAuthority authority {@link String} of the {@link CloudMediaProvider} + * @param timeout timeout in milliseconds for this query (<= 0 for timeout) + * @return the current cloud media collection info for the {@link CloudMediaProvider} with the * given {@code cloudMediaProviderAuthority}. */ @Nullable - public static String getCloudMediaAccountName(@NonNull ContentResolver resolver, - @Nullable String cloudMediaProviderAuthority) { + public static Bundle getCloudMediaCollectionInfo(@NonNull ContentResolver resolver, + @Nullable String cloudMediaProviderAuthority, @DurationMillisLong long timeout) + throws ExecutionException, InterruptedException, TimeoutException { if (cloudMediaProviderAuthority == null) { return null; } - try (ContentProviderClient client = - resolver.acquireContentProviderClient(cloudMediaProviderAuthority)) { - final Bundle out = client.call(METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null, - /* extras */ null); - return out.getString(ACCOUNT_NAME); - } catch (RemoteException e) { - throw e.rethrowAsRuntimeException(); - } + CompletableFuture<Bundle> future = CompletableFuture.supplyAsync(() -> + resolver.call(getMediaCollectionInfoUri(cloudMediaProviderAuthority), + METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null, /* extras */ null)); + + return (timeout > 0) ? future.get(timeout, MILLISECONDS) : future.get(); } } diff --git a/src/com/android/providers/media/photopicker/util/ThreadUtils.java b/src/com/android/providers/media/photopicker/util/ThreadUtils.java new file mode 100644 index 000000000..c3555d6a2 --- /dev/null +++ b/src/com/android/providers/media/photopicker/util/ThreadUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 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.util; + +import android.os.Looper; + +/** + * Provide the utility methods to handle thread. + */ +public class ThreadUtils { + /** + * Assert if the current {@link Thread} is the {@link androidx.annotation.MainThread}. + */ + public static void assertMainThread() { + if (Looper.getMainLooper().isCurrentThread()) { + return; + } + throw new IllegalStateException("Must be called from the Main thread. Current thread: " + + Thread.currentThread()); + } + + /** + * Assert if the current {@link Thread} is NOT the {@link androidx.annotation.MainThread}. + */ + public static void assertNonMainThread() { + if (Looper.getMainLooper().isCurrentThread()) { + throw new IllegalStateException("Must NOT be called from the Main thread." + + " Current thread: " + Thread.currentThread()); + } + } +} diff --git a/src/com/android/providers/media/photopicker/util/exceptions/UnableToAcquireLockException.java b/src/com/android/providers/media/photopicker/util/exceptions/UnableToAcquireLockException.java new file mode 100644 index 000000000..fad0c0160 --- /dev/null +++ b/src/com/android/providers/media/photopicker/util/exceptions/UnableToAcquireLockException.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 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.util.exceptions; + +/** + * Exception thrown when the current thread tries to acquire a lock but fails. The failure could be + * because of a timeout or thread interruption. + */ +public class UnableToAcquireLockException extends Exception { + public UnableToAcquireLockException(String message) { + super(message); + } + + public UnableToAcquireLockException(String message, Exception e) { + super(message, e); + } +} diff --git a/src/com/android/providers/media/photopicker/viewmodel/BannerController.java b/src/com/android/providers/media/photopicker/viewmodel/BannerController.java index 746ebd619..0087adc15 100644 --- a/src/com/android/providers/media/photopicker/viewmodel/BannerController.java +++ b/src/com/android/providers/media/photopicker/viewmodel/BannerController.java @@ -18,16 +18,17 @@ package com.android.providers.media.photopicker.viewmodel; import static android.provider.MediaStore.getCurrentCloudProvider; -import static com.android.providers.media.MediaApplication.getConfigStore; import static com.android.providers.media.photopicker.util.CloudProviderUtils.getAvailableCloudProviders; -import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaAccountName; +import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaCollectionInfo; import static com.android.providers.media.photopicker.util.CloudProviderUtils.getProviderLabelForUser; import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; -import android.os.Looper; +import android.os.Bundle; import android.os.UserHandle; +import android.provider.CloudMediaProviderContract.MediaCollectionInfo; import android.text.TextUtils; import android.util.AtomicFile; import android.util.Log; @@ -36,7 +37,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.providers.media.ConfigStore; import com.android.providers.media.photopicker.data.model.UserId; +import com.android.providers.media.photopicker.util.ThreadUtils; import com.android.providers.media.util.XmlUtils; import java.io.File; @@ -44,6 +47,8 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; /** * Banner Controller to store and handle the banner data per user for @@ -64,9 +69,11 @@ class BannerController { * {@link android.provider.CloudMediaProvider}. */ private static final String ACCOUNT_NAME = "account_name"; + private static final long GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS = 100L; private final Context mContext; private final UserHandle mUserHandle; + private final ConfigStore mConfigStore; /** * {@link File} for persisting the last fetched {@link android.provider.CloudMediaProvider} @@ -82,6 +89,9 @@ class BannerController { // Label of the current cloud media provider private String mCmpLabel; + // Account selection activity intent of the current cloud media provider + private Intent mChooseCloudMediaAccountActivityIntent; + // Boolean 'Choose App' banner visibility private boolean mShowChooseAppBanner; @@ -94,10 +104,12 @@ class BannerController { // Boolean 'Choose Account' banner visibility private boolean mShowChooseAccountBanner; - BannerController(@NonNull Context context, @NonNull UserHandle userHandle) { + BannerController(@NonNull Context context, @NonNull UserHandle userHandle, + @NonNull ConfigStore configStore) { Log.d(TAG, "Constructing the BannerController for user " + userHandle.getIdentifier()); mContext = context; mUserHandle = userHandle; + mConfigStore = configStore; final String lastCloudProviderDataFilePath = DATA_MEDIA_DIRECTORY_PATH + userHandle.getIdentifier() + LAST_CLOUD_PROVIDER_DATA_FILE_PATH_IN_USER_MEDIA_DIR; @@ -127,7 +139,9 @@ class BannerController { * block the UI thread on the heavy Binder calls to fetch the cloud media provider info. */ private void initialise() { - final String cmpAuthority, cmpAccountName; + String cmpAuthority = null, cmpAccountName = null; + mCmpLabel = null; + mChooseCloudMediaAccountActivityIntent = null; // TODO(b/245746037): Remove try-catch for the RuntimeException. // Under the hood MediaStore.getCurrentCloudProvider() makes an IPC call to the primary // MediaProvider process, where we currently perform a UID check (making sure that @@ -139,21 +153,30 @@ class BannerController { // check for MANAGE_CLOUD_MEDIA_PROVIDER permission. try { // 0. Assert non-main thread. - assertNonMainThread(); + ThreadUtils.assertNonMainThread(); // 1. Fetch the latest cloud provider info. final ContentResolver contentResolver = UserId.of(mUserHandle).getContentResolver(mContext); cmpAuthority = getCurrentCloudProvider(contentResolver); mCmpLabel = getProviderLabelForUser(mContext, mUserHandle, cmpAuthority); - cmpAccountName = getCloudMediaAccountName(contentResolver, cmpAuthority); + final Bundle cloudMediaCollectionInfo = getCloudMediaCollectionInfo(contentResolver, + cmpAuthority, GET_CLOUD_MEDIA_COLLECTION_INFO_TIMEOUT_IN_MILLIS); + if (cloudMediaCollectionInfo != null) { + cmpAccountName = cloudMediaCollectionInfo.getString( + MediaCollectionInfo.ACCOUNT_NAME); + mChooseCloudMediaAccountActivityIntent = cloudMediaCollectionInfo.getParcelable( + MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT); + } // Not logging the account name due to privacy concerns Log.d(TAG, "Current CloudMediaProvider authority: " + cmpAuthority + ", label: " + mCmpLabel); - } catch (PackageManager.NameNotFoundException | RuntimeException e) { + } catch (PackageManager.NameNotFoundException | RuntimeException | ExecutionException + | InterruptedException | TimeoutException e) { Log.w(TAG, "Could not fetch the current CloudMediaProvider", e); - resetToDefault(); + updateCloudProviderDataMap(cmpAuthority, cmpAccountName); + clearBanners(); return; } @@ -210,15 +233,6 @@ class BannerController { } /** - * Reset all the controller data to their default values. - */ - private void resetToDefault() { - mCloudProviderDataMap.clear(); - mCmpLabel = null; - clearBanners(); - } - - /** * Clear all banners * * Reset all should show banner {@code boolean} values to {@code false}. @@ -232,7 +246,7 @@ class BannerController { @VisibleForTesting boolean areCloudProviderOptionsAvailable() { - return !getAvailableCloudProviders(mContext, getConfigStore(), mUserHandle).isEmpty(); + return !getAvailableCloudProviders(mContext, mConfigStore, mUserHandle).isEmpty(); } /** @@ -260,6 +274,21 @@ class BannerController { } /** + * @return the account selection activity {@link Intent} of the current + * {@link android.provider.CloudMediaProvider}. + */ + @Nullable + Intent getChooseCloudMediaAccountActivityIntent() { + return mChooseCloudMediaAccountActivityIntent; + } + + @VisibleForTesting + void setChooseCloudMediaAccountActivityIntent( + @Nullable Intent chooseCloudMediaAccountActivityIntent) { + mChooseCloudMediaAccountActivityIntent = chooseCloudMediaAccountActivityIntent; + } + + /** * @return the 'Choose App' banner visibility {@link #mShowChooseAppBanner}. */ boolean shouldShowChooseAppBanner() { @@ -344,15 +373,6 @@ class BannerController { } } - private static void assertNonMainThread() { - if (!Looper.getMainLooper().isCurrentThread()) { - return; - } - - throw new IllegalStateException("Expected to NOT be called from the main thread." - + " Current thread: " + Thread.currentThread()); - } - private void loadCloudProviderInfo() { FileInputStream fis = null; final Map<String, String> lastCloudProviderDataMap = new HashMap<>(); @@ -382,6 +402,12 @@ class BannerController { private void persistCloudProviderInfo(@Nullable String cmpAuthority, @Nullable String cmpAccountName) { + updateCloudProviderDataMap(cmpAuthority, cmpAccountName); + updateCloudProviderDataFile(); + } + + private void updateCloudProviderDataMap(@Nullable String cmpAuthority, + @Nullable String cmpAccountName) { mCloudProviderDataMap.clear(); if (cmpAuthority != null) { mCloudProviderDataMap.put(AUTHORITY, cmpAuthority); @@ -389,8 +415,6 @@ class BannerController { if (cmpAccountName != null) { mCloudProviderDataMap.put(ACCOUNT_NAME, cmpAccountName); } - - updateCloudProviderDataFile(); } @VisibleForTesting diff --git a/src/com/android/providers/media/photopicker/viewmodel/BannerManager.java b/src/com/android/providers/media/photopicker/viewmodel/BannerManager.java index 04928e226..7601ee21b 100644 --- a/src/com/android/providers/media/photopicker/viewmodel/BannerManager.java +++ b/src/com/android/providers/media/photopicker/viewmodel/BannerManager.java @@ -16,23 +16,30 @@ package com.android.providers.media.photopicker.viewmodel; +import static com.android.providers.media.photopicker.DataLoaderThread.TOKEN; + import android.annotation.UserIdInt; import android.content.Context; +import android.content.Intent; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.UiThread; +import androidx.annotation.VisibleForTesting; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import com.android.providers.media.ConfigStore; +import com.android.providers.media.photopicker.DataLoaderThread; import com.android.providers.media.photopicker.data.UserIdManager; -import com.android.providers.media.util.ForegroundThread; +import com.android.providers.media.photopicker.util.ThreadUtils; import com.android.providers.media.util.PerUser; class BannerManager { private static final String TAG = "BannerManager"; + private static final int DELAY_MILLIS = 0; private final UserIdManager mUserIdManager; @@ -42,6 +49,8 @@ class BannerManager { private final MutableLiveData<String> mCloudMediaProviderLabel = new MutableLiveData<>(); // Account name of the current CloudMediaProvider of the current user private final MutableLiveData<String> mCloudMediaAccountName = new MutableLiveData<>(); + // Account selection activity intent of the current CloudMediaProvider of the current user + private Intent mChooseCloudMediaAccountActivityIntent = null; // Boolean Choose App Banner visibility private final MutableLiveData<Boolean> mShowChooseAppBanner = new MutableLiveData<>(false); @@ -56,18 +65,26 @@ class BannerManager { // The banner controllers per user private final PerUser<BannerController> mBannerControllers; - BannerManager(@NonNull Context context, @NonNull UserIdManager userIdManager) { + BannerManager(@NonNull Context context, @NonNull UserIdManager userIdManager, + @NonNull ConfigStore configStore) { mUserIdManager = userIdManager; mBannerControllers = new PerUser<BannerController>() { @NonNull @Override protected BannerController create(@UserIdInt int userId) { - return new BannerController(context, UserHandle.of(userId)); + return createBannerController(context, UserHandle.of(userId), configStore); } }; maybeInitialiseAndSetBannersForCurrentUser(); } + @VisibleForTesting + @NonNull + BannerController createBannerController(@NonNull Context context, + @NonNull UserHandle userHandle, @NonNull ConfigStore configStore) { + return new BannerController(context, userHandle, configStore); + } + @UserIdInt int getCurrentUserProfileId() { return mUserIdManager.getCurrentUserProfileId().getIdentifier(); } @@ -96,6 +113,24 @@ class BannerManager { } /** + * @return the account selection activity {@link Intent} of the current + * {@link android.provider.CloudMediaProvider}. + */ + @Nullable + Intent getChooseCloudMediaAccountActivityIntent() { + return mChooseCloudMediaAccountActivityIntent; + } + + + /** + * Update the account selection activity {@link Intent} of the current + * {@link android.provider.CloudMediaProvider}. + */ + void setChooseCloudMediaAccountActivityIntent(Intent intent) { + mChooseCloudMediaAccountActivityIntent = intent; + } + + /** * @return a {@link LiveData} that holds the value (once it's fetched) of the account name * of the current {@link android.provider.CloudMediaProvider}. */ @@ -139,8 +174,10 @@ class BannerManager { /** * Dismiss (hide) the 'Choose App' banner for the current user. */ - @UiThread + @MainThread void onUserDismissedChooseAppBanner() { + ThreadUtils.assertMainThread(); + if (Boolean.FALSE.equals(mShowChooseAppBanner.getValue())) { Log.d(TAG, "Choose App banner visibility live data value is false on dismiss"); } else { @@ -156,8 +193,10 @@ class BannerManager { /** * Dismiss (hide) the 'Cloud Media Available' banner for the current user. */ - @UiThread + @MainThread void onUserDismissedCloudMediaAvailableBanner() { + ThreadUtils.assertMainThread(); + if (Boolean.FALSE.equals(mShowCloudMediaAvailableBanner.getValue())) { Log.d(TAG, "Cloud Media Available banner visibility live data value is false on " + "dismiss"); @@ -174,8 +213,10 @@ class BannerManager { /** * Dismiss (hide) the 'Account Updated' banner for the current user. */ - @UiThread + @MainThread void onUserDismissedAccountUpdatedBanner() { + ThreadUtils.assertMainThread(); + if (Boolean.FALSE.equals(mShowAccountUpdatedBanner.getValue())) { Log.d(TAG, "Account Updated banner visibility live data value is false on dismiss"); } else { @@ -191,8 +232,10 @@ class BannerManager { /** * Dismiss (hide) the 'Choose Account' banner for the current user. */ - @UiThread + @MainThread void onUserDismissedChooseAccountBanner() { + ThreadUtils.assertMainThread(); + if (Boolean.FALSE.equals(mShowChooseAccountBanner.getValue())) { Log.d(TAG, "Choose Account banner visibility live data value is false on dismiss"); } else { @@ -212,50 +255,38 @@ class BannerManager { } /** - * Resets the banner controller per user. + * Resets the banner controller per user and sets the banner data for the current user. * * Note - Since {@link BannerController#reset()} cannot be called in the Main thread, using - * {@link ForegroundThread} here. + * {@link DataLoaderThread} here. */ - void maybeResetAllBannerData() { + void reset() { for (int arrayIndex = 0, numControllers = mBannerControllers.size(); arrayIndex < numControllers; arrayIndex++) { final BannerController bannerController = mBannerControllers.valueAt(arrayIndex); - ForegroundThread.getExecutor().execute(bannerController::reset); + DataLoaderThread.getHandler().postDelayed(bannerController::reset, TOKEN, DELAY_MILLIS); } - } - /** - * Update the banner {@link LiveData} values. - * - * 1. {@link #hideAllBanners()} in the Main thread to ensure consistency with the media items - * displayed for the period when the items and categories have been updated but the - * {@link BannerController} construction or {@link BannerController#reset()} is still in - * progress. - * - * 2. Initialise and set the banner data for the current user - * {@link #maybeInitialiseAndSetBannersForCurrentUser()}. - */ - @UiThread - void maybeUpdateBannerLiveDatas() { - // Hide all banners in the Main thread to ensure consistency with the media items - hideAllBanners(); - - // Initialise and set the banner data for the current user + // Set the banner data for the current user maybeInitialiseAndSetBannersForCurrentUser(); } /** - * Hide all banners in the Main thread. + * Hide all the banners in the DataLoader thread. + * + * Since this is always followed by a reset, they need to be done in the same threads (currently + * DataLoaderThread thread). For the case when multiple hideAllBanners & reset are triggered + * simultaneously, this ensures that they are called sequentially for each such trigger. * - * Set all banner {@link LiveData} values to {@code false}. + * Post all the banner {@link LiveData} values as {@code false}. */ - @UiThread - private void hideAllBanners() { - mShowChooseAppBanner.setValue(false); - mShowCloudMediaAvailableBanner.setValue(false); - mShowAccountUpdatedBanner.setValue(false); - mShowChooseAccountBanner.setValue(false); + void hideAllBanners() { + DataLoaderThread.getHandler().postDelayed(() -> { + mShowChooseAppBanner.postValue(false); + mShowCloudMediaAvailableBanner.postValue(false); + mShowAccountUpdatedBanner.postValue(false); + mShowChooseAccountBanner.postValue(false); + }, TOKEN, DELAY_MILLIS); } @@ -269,8 +300,9 @@ class BannerManager { } static class CloudBannerManager extends BannerManager { - CloudBannerManager(@NonNull Context context, @NonNull UserIdManager userIdManager) { - super(context, userIdManager); + CloudBannerManager(@NonNull Context context, @NonNull UserIdManager userIdManager, + @NonNull ConfigStore configStore) { + super(context, userIdManager, configStore); } /** @@ -279,14 +311,14 @@ class BannerManager { * 1. Get or create the {@link BannerController} for * {@link UserIdManager#getCurrentUserProfileId()} using {@link PerUser#forUser(int)}. * Since, the {@link BannerController} construction cannot be done in the Main thread, - * using {@link ForegroundThread} here. + * using {@link DataLoaderThread} here. * * 2. Post the updated {@link BannerController} {@link LiveData} values. */ @Override void maybeInitialiseAndSetBannersForCurrentUser() { final int currentUserProfileId = getCurrentUserProfileId(); - ForegroundThread.getExecutor().execute(() -> { + DataLoaderThread.getHandler().postDelayed(() -> { // Get (iff exists) or create the banner controller for the current user final BannerController bannerController = getBannerControllersPerUser().forUser(currentUserProfileId); @@ -297,6 +329,8 @@ class BannerManager { .postValue(bannerController.getCloudMediaProviderLabel()); getCloudMediaAccountNameLiveData() .postValue(bannerController.getCloudMediaProviderAccountName()); + setChooseCloudMediaAccountActivityIntent( + bannerController.getChooseCloudMediaAccountActivityIntent()); shouldShowChooseAppBannerLiveData() .postValue(bannerController.shouldShowChooseAppBanner()); shouldShowCloudMediaAvailableBannerLiveData() @@ -305,7 +339,7 @@ class BannerManager { .postValue(bannerController.shouldShowAccountUpdatedBanner()); shouldShowChooseAccountBannerLiveData() .postValue(bannerController.shouldShowChooseAccountBanner()); - }); + }, TOKEN, DELAY_MILLIS); } } } diff --git a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java index a0b0175ad..ff5e5c048 100644 --- a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java +++ b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java @@ -18,19 +18,45 @@ package com.android.providers.media.photopicker.viewmodel; import static android.content.Intent.ACTION_GET_CONTENT; import static android.content.Intent.EXTRA_LOCAL_ONLY; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS; +import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS; + +import static com.android.providers.media.PickerUriResolver.REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI; +import static com.android.providers.media.photopicker.DataLoaderThread.TOKEN; +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_GRID; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_DEFAULT; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_LOAD_NEXT_PAGE; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED; import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED; import android.annotation.SuppressLint; import android.app.Application; +import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.database.ContentObserver; import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; @@ -42,23 +68,35 @@ import androidx.lifecycle.Observer; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; +import com.android.modules.utils.BackgroundThread; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.ConfigStore; import com.android.providers.media.MediaApplication; +import com.android.providers.media.photopicker.DataLoaderThread; +import com.android.providers.media.photopicker.NotificationContentObserver; import com.android.providers.media.photopicker.data.ItemsProvider; import com.android.providers.media.photopicker.data.MuteStatus; +import com.android.providers.media.photopicker.data.PaginationParameters; import com.android.providers.media.photopicker.data.Selection; import com.android.providers.media.photopicker.data.UserIdManager; import com.android.providers.media.photopicker.data.model.Category; import com.android.providers.media.photopicker.data.model.Item; import com.android.providers.media.photopicker.data.model.UserId; +import com.android.providers.media.photopicker.metrics.NonUiEventLogger; import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; +import com.android.providers.media.photopicker.ui.ItemsAction; +import com.android.providers.media.photopicker.util.CategoryOrganiserUtils; import com.android.providers.media.photopicker.util.MimeFilterUtils; -import com.android.providers.media.util.ForegroundThread; +import com.android.providers.media.photopicker.util.ThreadUtils; import com.android.providers.media.util.MimeUtils; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * PickerViewModel to store and handle data for PhotoPickerActivity. @@ -69,38 +107,71 @@ public class PickerViewModel extends AndroidViewModel { private static final int RECENT_MINIMUM_COUNT = 12; private static final int INSTANCE_ID_MAX = 1 << 15; + private static final int DELAY_MILLIS = 0; + + // Token for the tasks to load the category items in the data loader thread's queue + private final Object mLoadCategoryItemsThreadToken = new Object(); @NonNull @SuppressLint("StaticFieldLeak") private final Context mAppContext; private final Selection mSelection; + + private int mPackageUid = -1; + private final MuteStatus mMuteStatus; + public boolean mEmptyPageDisplayed = false; // TODO(b/193857982): We keep these four data sets now, we may need to find a way to reduce the // data set to reduce memories. // The list of Items with all photos and videos - private MutableLiveData<List<Item>> mItemList; + private MutableLiveData<PaginatedItemsResult> mItemsResult; + private int mItemsPageSize = -1; + // The list of Items with all photos and videos in category - private MutableLiveData<List<Item>> mCategoryItemList; + private MutableLiveData<PaginatedItemsResult> mCategoryItemsResult; + + private int mCategoryItemsPageSize = -1; + // The list of categories. private MutableLiveData<List<Category>> mCategoryList; + private MutableLiveData<Boolean> mIsAllPreGrantedMediaLoaded = new MutableLiveData<>(false); + private final MutableLiveData<Boolean> mShouldRefreshUiLiveData = new MutableLiveData<>(false); + private final ContentObserver mRefreshUiNotificationObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + mShouldRefreshUiLiveData.postValue(true); + } + }; + + private MutableLiveData<Boolean> mIsSyncInProgress = new MutableLiveData<>(false); + private ItemsProvider mItemsProvider; private UserIdManager mUserIdManager; private BannerManager mBannerManager; private InstanceId mInstanceId; private PhotoPickerUiEventLogger mLogger; + private ConfigStore mConfigStore; private String[] mMimeTypeFilters = null; private int mBottomSheetState; private Category mCurrentCategory; + // Content resolver for the currently selected user + private ContentResolver mContentResolver; + // Note - Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates private boolean mIsUserSelectForApp; + + private boolean mIsManagedSelectionEnabled; private boolean mIsLocalOnly; + private boolean mIsAllCategoryItemsLoaded = false; + private boolean mIsNotificationForUpdateReceived = false; + private CancellationSignal mCancellationSignal = new CancellationSignal(); public PickerViewModel(@NonNull Application application) { super(application); @@ -112,9 +183,53 @@ public class PickerViewModel extends AndroidViewModel { mInstanceId = new InstanceIdSequence(INSTANCE_ID_MAX).newInstanceId(); mLogger = new PhotoPickerUiEventLogger(); mIsUserSelectForApp = false; + mIsManagedSelectionEnabled = false; mIsLocalOnly = false; - // Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates - initBannerManager(); + + initConfigStore(); + + // When the user opens the PhotoPickerSettingsActivity and changes the cloud provider, it's + // possible that system kills PhotoPickerActivity and PickerViewModel while it's in the + // background. In these scenarios, content observer will be unregistered and PickerViewModel + // will not be able to receive CMP change notifications. + initPhotoPickerData(); + registerRefreshUiNotificationObserver(); + // Add notification content observer for any notifications received for changes in media. + NotificationContentObserver contentObserver = new NotificationContentObserver(null); + contentObserver.registerKeysToObserverCallback( + Arrays.asList(NotificationContentObserver.MEDIA), + (dateTakenMs, albumId) -> { + onNotificationReceived(); + }); + contentObserver.register(mAppContext.getContentResolver()); + } + + @Override + protected void onCleared() { + unregisterRefreshUiNotificationObserver(); + + // Signal ContentProvider to cancel currently running task. + mCancellationSignal.cancel(); + + clearQueuedTasksInDataLoaderThread(); + } + + private void onNotificationReceived() { + Log.d(TAG, "Notification for media update has been received"); + mIsNotificationForUpdateReceived = true; + if (mEmptyPageDisplayed && mConfigStore.isCloudMediaInPhotoPickerEnabled()) { + (new Handler(Looper.getMainLooper())).post(() -> { + Log.d(TAG, "Refreshing UI to display new items."); + mEmptyPageDisplayed = false; + getPaginatedItemsForAction(ACTION_REFRESH_ITEMS, + new PaginationParameters(mItemsPageSize, -1, -1)); + }); + } + } + + @VisibleForTesting + protected void initConfigStore() { + mConfigStore = MediaApplication.getConfigStore(); } @VisibleForTesting @@ -127,6 +242,37 @@ public class PickerViewModel extends AndroidViewModel { mUserIdManager = userIdManager; } + @VisibleForTesting + public void setBannerManager(@NonNull BannerManager bannerManager) { + mBannerManager = bannerManager; + } + + @VisibleForTesting + public void setNotificationForUpdateReceived(boolean notificationForUpdateReceived) { + mIsNotificationForUpdateReceived = notificationForUpdateReceived; + } + + @VisibleForTesting + public void setLogger(@NonNull PhotoPickerUiEventLogger logger) { + mLogger = logger; + } + + @VisibleForTesting + public void setConfigStore(@NonNull ConfigStore configStore) { + mConfigStore = configStore; + } + + public void setEmptyPageDisplayed(boolean emptyPageDisplayed) { + mEmptyPageDisplayed = emptyPageDisplayed; + } + + /** + * @return the {@link ConfigStore} for this context. + */ + public ConfigStore getConfigStore() { + return mConfigStore; + } + /** * @return {@link UserIdManager} for this context. */ @@ -141,7 +287,6 @@ public class PickerViewModel extends AndroidViewModel { return mSelection; } - /** * @return {@code mMuteStatus} that tracks the volume mute status of the video preview */ @@ -151,16 +296,25 @@ public class PickerViewModel extends AndroidViewModel { /** * @return {@code mIsUserSelectForApp} if the picker is currently being used - * for the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP} action. + * for the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP} action. */ public boolean isUserSelectForApp() { return mIsUserSelectForApp; } /** + * @return {@code mIsManagedSelectionEnabled} if the picker is currently being used + * for the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP} action and flag + * pickerChoiceManagedSelection is enabled.. + */ + public boolean isManagedSelectionEnabled() { + return mIsManagedSelectionEnabled; + } + + /** * @return a {@link LiveData} that holds the value (once it's fetched) of the - * {@link android.content.ContentProvider#mAuthority authority} of the current - * {@link android.provider.CloudMediaProvider}. + * {@link android.content.ContentProvider#mAuthority authority} of the current + * {@link android.provider.CloudMediaProvider}. */ @NonNull public LiveData<String> getCloudMediaProviderAuthorityLiveData() { @@ -169,7 +323,7 @@ public class PickerViewModel extends AndroidViewModel { /** * @return a {@link LiveData} that holds the value (once it's fetched) of the label - * of the current {@link android.provider.CloudMediaProvider}. + * of the current {@link android.provider.CloudMediaProvider}. */ @NonNull public LiveData<String> getCloudMediaProviderAppTitleLiveData() { @@ -178,7 +332,7 @@ public class PickerViewModel extends AndroidViewModel { /** * @return a {@link LiveData} that holds the value (once it's fetched) of the account name - * of the current {@link android.provider.CloudMediaProvider}. + * of the current {@link android.provider.CloudMediaProvider}. */ @NonNull public LiveData<String> getCloudMediaAccountNameLiveData() { @@ -186,137 +340,434 @@ public class PickerViewModel extends AndroidViewModel { } /** - * Reset PickerViewModel. - * @param switchToPersonalProfile is true then set personal profile as current profile. + * @return the account selection activity {@link Intent} of the current + * {@link android.provider.CloudMediaProvider}. + */ + @Nullable + public Intent getChooseCloudMediaAccountActivityIntent() { + return mBannerManager.getChooseCloudMediaAccountActivityIntent(); + } + + /** + * Reset to personal profile mode. */ @UiThread - public void reset(boolean switchToPersonalProfile) { - // 1. Clear Selected items + public void resetToPersonalProfile() { + mUserIdManager.setPersonalAsCurrentUserProfile(); + onSwitchedProfile(); + } + + /** + * Reset the content observer & all the content on profile switched. + */ + @UiThread + public void onSwitchedProfile() { + resetRefreshUiNotificationObserver(); + resetAllContentInCurrentProfile(); + } + + /** + * Reset all the content (items, categories & banners) in the current profile. + */ + @UiThread + public void resetAllContentInCurrentProfile() { + Log.d(TAG, "Reset all content in current profile"); + + // Post 'should refresh UI live data' value as false to avoid unnecessary repetitive resets + mShouldRefreshUiLiveData.postValue(false); + + clearQueuedTasksInDataLoaderThread(); + + initPhotoPickerData(); + + // Clear the existing content - selection, photos grid, albums grid, banners mSelection.clearSelectedItems(); - // 2. Change profile to personal user - if (switchToPersonalProfile) { - mUserIdManager.setPersonalAsCurrentUserProfile(); + + if (mItemsResult != null) { + DataLoaderThread.getHandler().postDelayed(() -> + mItemsResult.postValue(new PaginatedItemsResult(List.of(Item.EMPTY_VIEW), + ACTION_CLEAR_GRID)), TOKEN, DELAY_MILLIS); + } + + if (mCategoryList != null) { + DataLoaderThread.getHandler().postDelayed(() -> + mCategoryList.postValue(List.of(Category.EMPTY_VIEW)), TOKEN, DELAY_MILLIS); } - // 3. Update Item and Category lists - updateItems(); + + mBannerManager.hideAllBanners(); + + // Update items, categories & banners + getPaginatedItemsForAction(ACTION_CLEAR_AND_UPDATE_LIST, null); updateCategories(); - // 4. Update Banners - // Note - Banners should always be updated after the items & categories to ensure a - // consistent UI. - mBannerManager.maybeResetAllBannerData(); - mBannerManager.maybeUpdateBannerLiveDatas(); + mBannerManager.reset(); } /** - * Update items, categories & banners on profile switched by the user. + * Loads list of pre granted items for the current package and userID. */ - @UiThread - public void onUserSwitchedProfile() { - updateItems(); - updateCategories(); - // Note - Banners should always be updated after the items & categories to ensure a - // consistent UI. - mBannerManager.maybeUpdateBannerLiveDatas(); + public void initialisePreGrantsIfNecessary(Selection selection, Bundle intentExtras, + String[] mimeTypeFilters) { + if (isManagedSelectionEnabled() && selection.getPreGrantedItems() == null) { + DataLoaderThread.getHandler().postDelayed(() -> { + Set<String> preGrantedItems = mItemsProvider.fetchReadGrantedItemsUrisForPackage( + intentExtras.getInt(Intent.EXTRA_UID), mimeTypeFilters) + .stream().map((Uri uri) -> String.valueOf(ContentUris.parseId(uri))) + .collect(Collectors.toSet()); + selection.setPreGrantedItemSet(preGrantedItems); + logPickerChoiceInitGrantsCount(preGrantedItems.size(), intentExtras); + }, TOKEN, DELAY_MILLIS); + } + } + + /** + * Performs required modification to the item list and returns the live data for it. + */ + public LiveData<PaginatedItemsResult> getPaginatedItemsForAction( + @NonNull @ItemsAction.Type int action, + @Nullable PaginationParameters paginationParameters) { + Objects.requireNonNull(action); + switch (action) { + case ACTION_VIEW_CREATED: { + // Use this when a fresh view is created. If the current list is empty, it will + // load the first page and return the list, else it will return previously + // existing values. + mItemsPageSize = paginationParameters.getPageSize(); + if (mItemsResult == null) { + updatePaginatedItems(paginationParameters, true, action); + } + break; + } + case ACTION_LOAD_NEXT_PAGE: { + // Loads next page of the list, using the previously loaded list. + // If the current list is empty then it will not perform any actions. + if (mItemsResult != null && mItemsResult.getValue() != null) { + List<Item> currentItemList = mItemsResult.getValue().getItems(); + // If the list is already empty that would mean that the first page was not + // loaded since there were no items to be loaded. + if (currentItemList != null && !currentItemList.isEmpty()) { + // get the last item of the existing list. + Item item = currentItemList.get(currentItemList.size() - 1); + updatePaginatedItems( + new PaginationParameters(mItemsPageSize, item.getDateTaken(), + item.getRowId()), false, action); + } + } + break; + } + case ACTION_CLEAR_AND_UPDATE_LIST: { + // Clears the existing list and loads the list with for mItemsPageSize + // number of items. This will be equal to page size for pagination if cloud + // picker feature flag is enabled, else it will be -1 implying that the complete + // list should be loaded. + updatePaginatedItems(new PaginationParameters(mItemsPageSize, + /*dateBeforeMs*/ Long.MIN_VALUE, /*rowId*/ -1), /* isReset */ true, action); + break; + } + case ACTION_REFRESH_ITEMS: { + if (mIsNotificationForUpdateReceived + && mItemsResult != null + && mItemsResult.getValue() != null) { + updatePaginatedItems(paginationParameters, true, action); + mIsNotificationForUpdateReceived = false; + } + break; + } + default: + Log.w(TAG, "Invalid action passed to fetch items"); + } + return mItemsResult; } /** - * @return the list of Items with all photos and videos {@link #mItemList} on the device. + * Update the item List {@link #mItemsResult}. Loads the page requested represented by the + * pagination parameters and replaces/appends it to the existing list of items based on the + * reset value. */ - public LiveData<List<Item>> getItems() { - if (mItemList == null) { - updateItems(); + private void updatePaginatedItems(PaginationParameters pagingParameters, boolean isReset, + @ItemsAction.Type int action) { + if (mItemsResult == null) { + mItemsResult = new MutableLiveData<>(); } - return mItemList; + loadItemsAsync(pagingParameters, /* isReset */ isReset, action); } - private List<Item> loadItems(Category category, UserId userId) { + /** + * Loads required items and sets it to the {@link PickerViewModel#mItemsResult} while + * considering the isReset value. + * + * @param pagingParameters parameters representing the items that needs to be loaded next. + * @param isReset If this is true, clear the pre-existing list and add the newly loaded + * items. + * @param action This is used while posting the result of the operation. + */ + private void loadItemsAsync(@NonNull PaginationParameters pagingParameters, boolean isReset, + @ItemsAction.Type int action) { + final UserId userId = mUserIdManager.getCurrentUserProfileId(); + + DataLoaderThread.getHandler().postDelayed(() -> { + // Load the items as per the pagination parameters passed as params to this method. + List<Item> newPageItemList = loadItems(Category.DEFAULT, userId, pagingParameters); + + // Based on if it is a reset case or not, create an updated list. + // If it is a reset case, assign an empty list else use the contents of the pre-existing + // list. Then add the newly loaded items. + List<Item> updatedList = + mItemsResult.getValue() == null || isReset ? new ArrayList<>() + : mItemsResult.getValue().getItems(); + updatedList.addAll(newPageItemList); + Log.d(TAG, "Next page for photos items have been loaded."); + if (newPageItemList.isEmpty()) { + Log.d(TAG, "All photos items have been loaded."); + } + + // post the result with the action. + mItemsResult.postValue(new PaginatedItemsResult(updatedList, action)); + mIsSyncInProgress.postValue(false); + }, TOKEN, DELAY_MILLIS); + } + + private List<Item> loadItems(Category category, UserId userId, + PaginationParameters pagingParameters) { final List<Item> items = new ArrayList<>(); + String cloudProviderAuthority = null; // NULL if fetched items have NO cloud only media item - try (Cursor cursor = fetchItems(category, userId)) { + try (Cursor cursor = fetchItems(category, userId, pagingParameters)) { if (cursor == null || cursor.getCount() == 0) { Log.d(TAG, "Didn't receive any items for " + category + ", either cursor is null or cursor count is zero"); return items; } + Set<String> preGrantedItems = new HashSet<>(0); + Set<String> deSelectedPreGrantedItems = new HashSet<>(0); + if (isManagedSelectionEnabled() && mSelection.getPreGrantedItems() != null) { + preGrantedItems = mSelection.getPreGrantedItems(); + deSelectedPreGrantedItems = new HashSet<>( + mSelection.getPreGrantedItemIdsToBeRevoked()); + } 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)); + final Item item = Item.fromCursor(cursor, userId); + if (preGrantedItems.contains(item.getId())) { + item.setPreGranted(); + if (!deSelectedPreGrantedItems.contains(item.getId())) { + mSelection.addSelectedItem(item); + } + } + String authority = item.getContentUri().getAuthority(); + + if (!LOCAL_PICKER_PROVIDER_AUTHORITY.equals(authority)) { + cloudProviderAuthority = authority; + } + items.add(item); + } + + Log.d(TAG, "Loaded " + items.size() + " items in " + category + " for user " + + userId.toString()); + return items; + } finally { + int count = items.size(); + if (category.isDefault()) { + mLogger.logLoadedMainGridMediaItems(cloudProviderAuthority, mInstanceId, count); + } else { + mLogger.logLoadedAlbumGridMediaItems(cloudProviderAuthority, mInstanceId, count); } } + } + + /** + * @return true when all pre-granted items data has been loaded for this session. + */ + @NonNull + public MutableLiveData<Boolean> getIsAllPreGrantedMediaLoaded() { + return mIsAllPreGrantedMediaLoaded; + } - Log.d(TAG, "Loaded " + items.size() + " items in " + category + " for user " - + userId.toString()); - return items; + /** + * Gets item data for Uris which have not yet been loaded to the UI. This is important when the + * preview fragment is created and hence should be called only before creation. + * + * <p>This is used during pagination. All the items are not loaded at once and hence the + * preGranted item which is on a page that is yet to be loaded will would not be part of the + * mSelected list and hence will not show up in the preview fragment. This method fixes this + * issue by selectively loading those items and adding them to the selection list.</p> + */ + public void getRemainingPreGrantedItems() { + if (!isManagedSelectionEnabled() || mSelection.getPreGrantedItems() == null) return; + + List<String> idsForItemsToBeFetched = + new ArrayList<>(mSelection.getPreGrantedItems()); + idsForItemsToBeFetched.removeAll(mSelection.getSelectedItemsIds()); + idsForItemsToBeFetched.removeAll(mSelection.getPreGrantedItemIdsToBeRevoked()); + + if (!idsForItemsToBeFetched.isEmpty()) { + final UserId userId = mUserIdManager.getCurrentUserProfileId(); + DataLoaderThread.getHandler().postDelayed(() -> { + loadItemsWithLocalIdSelection(Category.DEFAULT, userId, + idsForItemsToBeFetched.stream().map(Integer::valueOf).collect( + Collectors.toList())); + // If new data has loaded then post value representing a successful operation. + mIsAllPreGrantedMediaLoaded.postValue(true); + Log.d(TAG, "Fetched " + idsForItemsToBeFetched.size() + + " items for required preGranted ids"); + }, TOKEN, 0); + } } - private Cursor fetchItems(Category category, UserId userId) { - if (shouldShowOnlyLocalFeatures()) { - return mItemsProvider.getLocalItems(category, /* limit */ -1, mMimeTypeFilters, userId); - } else { - return mItemsProvider.getAllItems(category, /* limit */ -1, mMimeTypeFilters, userId); + private void loadItemsWithLocalIdSelection(Category category, UserId userId, + List<Integer> selectionArg) { + try (Cursor cursor = mItemsProvider.getLocalItemsForSelection(category, selectionArg, + mMimeTypeFilters, userId, mCancellationSignal)) { + if (cursor == null || cursor.getCount() == 0) { + Log.d(TAG, "Didn't receive any items for pre granted URIs" + category + + ", either cursor is null or cursor count is zero"); + return; + } + + Set<String> selectedIdSet = new HashSet<>(mSelection.getSelectedItemsIds()); + // Add all loaded items to selection after marking them as pre granted. + while (cursor.moveToNext()) { + final Item item = Item.fromCursor(cursor, userId); + item.setPreGranted(); + if (!selectedIdSet.contains(item.getId())) { + mSelection.addSelectedItem(item); + } + } + Log.d(TAG, "Pre granted items have been loaded."); } } - private void loadItemsAsync() { - final UserId userId = mUserIdManager.getCurrentUserProfileId(); - ForegroundThread.getExecutor().execute(() -> { - mItemList.postValue(loadItems(Category.DEFAULT, userId)); - }); + private Cursor fetchItems(Category category, UserId userId, + PaginationParameters pagingParameters) { + try { + if (shouldShowOnlyLocalFeatures()) { + return mItemsProvider.getLocalItems(category, pagingParameters, + mMimeTypeFilters, userId, mCancellationSignal); + } else { + return mItemsProvider.getAllItems(category, pagingParameters, + mMimeTypeFilters, userId, mCancellationSignal); + } + } catch (RuntimeException ignored) { + // Catch OperationCanceledException. + Log.e(TAG, "Failed to fetch items due to a runtime exception", ignored); + return null; + } } /** - * Update the item List {@link #mItemList} + * Modifies and returns the live data for category items. */ - public void updateItems() { - if (mItemList == null) { - mItemList = new MutableLiveData<>(); + public LiveData<PaginatedItemsResult> getPaginatedCategoryItemsForAction( + @NonNull Category category, + @ItemsAction.Type int action, @Nullable PaginationParameters paginationParameters) { + switch (action) { + case ACTION_VIEW_CREATED: { + // This call is made only for loading the first page of album media, + // so the existing data loader thread tasks for updating the category items should + // be cleared and the category and category item list should be refreshed each time. + DataLoaderThread.getHandler().removeCallbacksAndMessages( + mLoadCategoryItemsThreadToken); + mCategoryItemsResult = new MutableLiveData<>(); + mCurrentCategory = category; + assert paginationParameters != null; + mCategoryItemsPageSize = paginationParameters.getPageSize(); + updateCategoryItems(paginationParameters, action); + break; + } + case ACTION_LOAD_NEXT_PAGE: { + // Loads next page of the list, using the previously loaded list. + // If the current list is empty then it will not perform any actions. + if (mCategoryItemsResult == null || mCategoryItemsResult.getValue() == null + || !TextUtils.equals(mCurrentCategory.getId(), + category.getId())) { + break; + } + List<Item> currentItemList = mCategoryItemsResult.getValue().getItems(); + // If the categoryItemList does not contain any items, it would mean that the first + // page was empty. + if (currentItemList != null && !currentItemList.isEmpty()) { + Item item = currentItemList.get(currentItemList.size() - 1); + PaginationParameters pagingParams = new PaginationParameters( + mCategoryItemsPageSize, + item.getDateTaken(), + item.getRowId()); + updateCategoryItems(pagingParams, action); + } + break; + } + default: + Log.w(TAG, "Invalid action passed to fetch category items"); } - loadItemsAsync(); + return mCategoryItemsResult; } /** - * Get the list of all photos and videos with the specific {@code category} on the device. + * Update the item List with the {@link #mCurrentCategory} {@link #mCategoryItemsResult} * - * In our use case, we only keep the list of current category {@link #mCurrentCategory} in - * {@link #mCategoryItemList}. If the {@code category} and {@link #mCurrentCategory} are - * different, we will create the new LiveData to {@link #mCategoryItemList}. - * - * @param category the category we want to be queried - * @return the list of all photos and videos with the specific {@code category} - * {@link #mCategoryItemList} + * @throws IllegalStateException category and category items is not initiated before calling + * this method */ - public LiveData<List<Item>> getCategoryItems(@NonNull Category category) { - if (mCategoryItemList == null || !TextUtils.equals(mCurrentCategory.getId(), - category.getId())) { - mCategoryItemList = new MutableLiveData<>(); - mCurrentCategory = category; + @VisibleForTesting + public void updateCategoryItems(PaginationParameters pagingParameters, + @ItemsAction.Type int action) { + if (mCategoryItemsResult == null || mCurrentCategory == null) { + throw new IllegalStateException("mCurrentCategory and mCategoryItemsResult are not" + + " initiated. Please call getCategoryItems before calling this method"); } - updateCategoryItems(); - return mCategoryItemList; + loadCategoryItemsAsync(pagingParameters, action != ACTION_LOAD_NEXT_PAGE, action); } - private void loadCategoryItemsAsync() { + /** + * Loads required category items and sets it to the {@link PickerViewModel#mCategoryItemsResult} + * while considering the isReset value. + * + * @param pagingParameters parameters representing the items that needs to be loaded next. + * @param isReset If this is true, clear the pre-existing list and add the newly loaded + * items. + * @param action This is used while posting the result of the operation. + */ + private void loadCategoryItemsAsync(PaginationParameters pagingParameters, boolean isReset, + @ItemsAction.Type int action) { final UserId userId = mUserIdManager.getCurrentUserProfileId(); - ForegroundThread.getExecutor().execute(() -> { - mCategoryItemList.postValue(loadItems(mCurrentCategory, userId)); - }); + final Category category = mCurrentCategory; + + DataLoaderThread.getHandler().postDelayed(() -> { + if (action == ACTION_LOAD_NEXT_PAGE && mIsAllCategoryItemsLoaded) { + return; + } + // Load the items as per the pagination parameters passed as params to this method. + List<Item> newPageItemList = loadItems(category, userId, pagingParameters); + + // Based on if it is a reset case or not, create an updated list. + // If it is a reset case, assign an empty list else use the contents of the pre-existing + // list. Then add the newly loaded items. + List<Item> updatedList = mCategoryItemsResult.getValue() == null || isReset + ? new ArrayList<>() : mCategoryItemsResult.getValue().getItems(); + updatedList.addAll(newPageItemList); + + if (isReset) { + mIsAllCategoryItemsLoaded = false; + } + Log.d(TAG, "Next page for category items have been loaded. Category: " + + category + " " + updatedList.size()); + if (newPageItemList.isEmpty()) { + mIsAllCategoryItemsLoaded = true; + Log.d(TAG, "All items have been loaded for category: " + mCurrentCategory); + } + if (Objects.equals(category, mCurrentCategory)) { + mCategoryItemsResult.postValue(new PaginatedItemsResult(updatedList, action)); + } + }, mLoadCategoryItemsThreadToken, DELAY_MILLIS); } /** - * Update the item List with the {@link #mCurrentCategory} {@link #mCategoryItemList} - * - * @throws IllegalStateException category and category items is not initiated before calling - * this method + * Used only for testing, clears out any data in item list and category item list. */ @VisibleForTesting - public void updateCategoryItems() { - if (mCategoryItemList == null || mCurrentCategory == null) { - throw new IllegalStateException("mCurrentCategory and mCategoryItemList are not" - + " initiated. Please call getCategoryItems before calling this method"); - } - loadCategoryItemsAsync(); + public void clearItemsAndCategoryItemsList() { + mItemsResult = null; + mCategoryItemsResult = null; } /** @@ -331,6 +782,7 @@ public class PickerViewModel extends AndroidViewModel { private List<Category> loadCategories(UserId userId) { final List<Category> categoryList = new ArrayList<>(); + String cloudProviderAuthority = null; // NULL if fetched albums have NO cloud album try (Cursor cursor = fetchCategories(userId)) { if (cursor == null || cursor.getCount() == 0) { Log.d(TAG, "Didn't receive any categories, either cursor is null or" @@ -340,28 +792,44 @@ public class PickerViewModel extends AndroidViewModel { while (cursor.moveToNext()) { final Category category = Category.fromCursor(cursor, userId); + String authority = category.getAuthority(); + + if (!LOCAL_PICKER_PROVIDER_AUTHORITY.equals(authority)) { + cloudProviderAuthority = authority; + } categoryList.add(category); } Log.d(TAG, "Loaded " + categoryList.size() + " categories for user " + userId.toString()); + CategoryOrganiserUtils.getReorganisedCategoryList(categoryList); + return categoryList; + } finally { + mLogger.logLoadedAlbums(cloudProviderAuthority, mInstanceId, categoryList.size()); } - return categoryList; } private Cursor fetchCategories(UserId userId) { - if (shouldShowOnlyLocalFeatures()) { - return mItemsProvider.getLocalCategories(mMimeTypeFilters, userId); - } else { - return mItemsProvider.getAllCategories(mMimeTypeFilters, userId); + try { + if (shouldShowOnlyLocalFeatures()) { + return mItemsProvider + .getLocalCategories(mMimeTypeFilters, userId, mCancellationSignal); + } else { + return mItemsProvider + .getAllCategories(mMimeTypeFilters, userId, mCancellationSignal); + } + } catch (RuntimeException ignored) { + // Catch OperationCanceledException. + Log.e(TAG, "Failed to fetch categories due to a runtime exception", ignored); + return null; } } private void loadCategoriesAsync() { final UserId userId = mUserIdManager.getCurrentUserProfileId(); - ForegroundThread.getExecutor().execute(() -> { + DataLoaderThread.getHandler().postDelayed(() -> { mCategoryList.postValue(loadCategories(userId)); - }); + }, TOKEN, DELAY_MILLIS); } /** @@ -405,6 +873,8 @@ public class PickerViewModel extends AndroidViewModel { mIsUserSelectForApp = MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP.equals(intent.getAction()); + mIsManagedSelectionEnabled = mIsUserSelectForApp + && getConfigStore().isPickerChoiceManagedSelectionEnabled(); if (!SdkLevel.isAtLeastU() && mIsUserSelectForApp) { throw new IllegalArgumentException("ACTION_USER_SELECT_IMAGES_FOR_APP is not enabled " + " for this OS version"); @@ -414,20 +884,25 @@ public class PickerViewModel extends AndroidViewModel { // in the extras. if (mIsUserSelectForApp && (intent.getExtras() == null - || !intent.getExtras() - .containsKey(Intent.EXTRA_UID))) { + || !intent.getExtras() + .containsKey(Intent.EXTRA_UID))) { throw new IllegalArgumentException( "EXTRA_UID is required for" + " ACTION_USER_SELECT_IMAGES_FOR_APP"); } + if (mIsUserSelectForApp) { + mPackageUid = intent.getExtras().getInt(Intent.EXTRA_UID); + } // Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates - initBannerManager(); + if (mBannerManager == null) { + initBannerManager(); + } } private void initBannerManager() { mBannerManager = shouldShowOnlyLocalFeatures() - ? new BannerManager(mAppContext, mUserIdManager) - : new BannerManager.CloudBannerManager(mAppContext, mUserIdManager); + ? new BannerManager(mAppContext, mUserIdManager, mConfigStore) + : new BannerManager.CloudBannerManager(mAppContext, mUserIdManager, mConfigStore); } /** @@ -484,8 +959,6 @@ public class PickerViewModel extends AndroidViewModel { maybeLogPickerOpenedWithCloudProvider(); } - // TODO(b/245745412): Fix log params (uid & package name) - // TODO(b/245745424): Solve for active cloud provider without a logged in account private void maybeLogPickerOpenedWithCloudProvider() { if (shouldShowOnlyLocalFeatures()) { return; @@ -500,8 +973,8 @@ public class PickerViewModel extends AndroidViewModel { + ", log=" + (providerAuthority != null)); if (providerAuthority != null) { - mLogger.logPickerOpenWithActiveCloudProvider( - mInstanceId, /* cloudProviderUid */ -1, providerAuthority); + BackgroundThread.getExecutor().execute(() -> + logPickerOpenedWithCloudProvider(providerAuthority)); } // We only need to get the value once. cloudMediaProviderAuthorityLiveData.removeObserver(this); @@ -509,6 +982,27 @@ public class PickerViewModel extends AndroidViewModel { }); } + private void logPickerOpenedWithCloudProvider(@NonNull String providerAuthority) { + String cloudProviderPackage = providerAuthority; + int cloudProviderUid = -1; + + try { + final PackageManager packageManager = + UserId.CURRENT_USER.getPackageManager(mAppContext); + final ProviderInfo providerInfo = packageManager.resolveContentProvider( + providerAuthority, /* flags= */ 0); + + cloudProviderPackage = providerInfo.applicationInfo.packageName; + cloudProviderUid = providerInfo.applicationInfo.uid; + } catch (PackageManager.NameNotFoundException e) { + Log.d(TAG, "Logging the ui event 'picker open with an active cloud provider' with its " + + "authority in place of the package name and a default uid.", e); + } + + mLogger.logPickerOpenWithActiveCloudProvider( + mInstanceId, cloudProviderUid, cloudProviderPackage); + } + /** * Log metrics to notify that the user has clicked Browse to open DocumentsUi */ @@ -540,6 +1034,264 @@ public class PickerViewModel extends AndroidViewModel { } } + /** + * Log metrics to notify that the user has clicked the mute / unmute button in a video preview + */ + public void logVideoPreviewMuteButtonClick() { + mLogger.logVideoPreviewMuteButtonClick(mInstanceId); + } + + /** + * Log metrics to notify that the user has clicked the 'view selected' button + * + * @param selectedItemCount the number of items selected for preview all + */ + public void logPreviewAllSelected(int selectedItemCount) { + mLogger.logPreviewAllSelected(mInstanceId, selectedItemCount); + } + + /** + * Log metrics to notify that the 'switch profile' button is visible & enabled + */ + public void logProfileSwitchButtonEnabled() { + mLogger.logProfileSwitchButtonEnabled(mInstanceId); + } + + /** + * Log metrics to notify that the 'switch profile' button is visible but disabled + */ + public void logProfileSwitchButtonDisabled() { + mLogger.logProfileSwitchButtonDisabled(mInstanceId); + } + + /** + * Log metrics to notify that the user has clicked the 'switch profile' button + */ + public void logProfileSwitchButtonClick() { + mLogger.logProfileSwitchButtonClick(mInstanceId); + } + + /** + * Log metrics to notify that the user has cancelled the current session by swiping down + */ + public void logSwipeDownExit() { + mLogger.logSwipeDownExit(mInstanceId); + } + + /** + * Log metrics to notify that the user has made a back gesture + * @param backStackEntryCount the number of fragment entries currently in the back stack + */ + public void logBackGestureWithStackCount(int backStackEntryCount) { + mLogger.logBackGestureWithStackCount(mInstanceId, backStackEntryCount); + } + + /** + * Log metrics to notify that the user has clicked the action bar home button + * @param backStackEntryCount the number of fragment entries currently in the back stack + */ + public void logActionBarHomeButtonClick(int backStackEntryCount) { + mLogger.logActionBarHomeButtonClick(mInstanceId, backStackEntryCount); + } + + /** + * Log metrics to notify that the user has expanded from half screen to full + */ + public void logExpandToFullScreen() { + mLogger.logExpandToFullScreen(mInstanceId); + } + + /** + * Log metrics to notify that the user has opened the photo picker menu + */ + public void logMenuOpened() { + mLogger.logMenuOpened(mInstanceId); + } + + /** + * Log metrics to notify that the user has switched to the photos tab + */ + public void logSwitchToPhotosTab() { + mLogger.logSwitchToPhotosTab(mInstanceId); + } + + /** + * Log metrics to notify that the user has switched to the albums tab + */ + public void logSwitchToAlbumsTab() { + mLogger.logSwitchToAlbumsTab(mInstanceId); + } + + /** + * Log metrics to notify that the user has opened an album + * + * @param category the opened album metadata + * @param position the position of the album in the recycler view + */ + public void logAlbumOpened(@NonNull Category category, int position) { + final String albumId = category.getId(); + if (ALBUM_ID_FAVORITES.equals(albumId)) { + mLogger.logFavoritesAlbumOpened(mInstanceId); + } else if (ALBUM_ID_CAMERA.equals(albumId)) { + mLogger.logCameraAlbumOpened(mInstanceId); + } else if (ALBUM_ID_DOWNLOADS.equals(albumId)) { + mLogger.logDownloadsAlbumOpened(mInstanceId); + } else if (ALBUM_ID_SCREENSHOTS.equals(albumId)) { + mLogger.logScreenshotsAlbumOpened(mInstanceId); + } else if (ALBUM_ID_VIDEOS.equals(albumId)) { + mLogger.logVideosAlbumOpened(mInstanceId); + } else if (!category.isLocal()) { + mLogger.logCloudAlbumOpened(mInstanceId, position); + } + } + + /** + * Log metrics to notify that the user has selected a media item + * + * @param item the selected item metadata + * @param category the category of the item selected, {@link Category#DEFAULT} for main grid + * @param position the position of the album in the recycler view + */ + public void logMediaItemSelected(@NonNull Item item, @NonNull Category category, int position) { + if (category.isDefault()) { + mLogger.logSelectedMainGridItem(mInstanceId, position); + } else { + mLogger.logSelectedAlbumItem(mInstanceId, position); + } + + if (!item.isLocal()) { + mLogger.logSelectedCloudOnlyItem(mInstanceId, position); + } + } + + /** + * Log metrics to notify that the user has previewed a media item + * + * @param item the previewed item metadata + * @param category the category of the item previewed, {@link Category#DEFAULT} for main grid + * @param position the position of the album in the recycler view + */ + public void logMediaItemPreviewed( + @NonNull Item item, @NonNull Category category, int position) { + if (category.isDefault()) { + mLogger.logPreviewedMainGridItem( + item.getSpecialFormat(), item.getMimeType(), mInstanceId, position); + } + } + + /** + * Log metrics to notify create surface controller triggered + * @param authority the authority of the provider + */ + public void logCreateSurfaceControllerStart(String authority) { + mLogger.logPickerCreateSurfaceControllerStart(mInstanceId, authority); + } + + /** + * Log metrics to notify create surface controller ended + * @param authority the authority of the provider + */ + public void logCreateSurfaceControllerEnd(String authority) { + mLogger.logPickerCreateSurfaceControllerEnd(mInstanceId, authority); + } + + /** + * Log metrics to notify that the selected media preloading started + * @param count the number of items to preload + */ + public void logPreloadingStarted(int count) { + mLogger.logPreloadingStarted(mInstanceId, count); + } + + /** + * Log metrics to notify that the selected media preloading finished + */ + public void logPreloadingFinished() { + mLogger.logPreloadingFinished(mInstanceId); + } + + /** + * Log metrics to notify that the user cancelled the selected media preloading + * @param count the number of items pending to preload + */ + public void logPreloadingCancelled(int count) { + mLogger.logPreloadingCancelled(mInstanceId, count); + } + + /** + * Log metrics to notify that the selected media preloading failed for some items + * @param count the number of items pending / failed to preload + */ + public void logPreloadingFailed(int count) { + mLogger.logPreloadingFailed(mInstanceId, count); + } + + /** + * Logs metrics for count of grants initialised for a package. + */ + public void logPickerChoiceInitGrantsCount(int numberOfGrants, Bundle intentExtras) { + NonUiEventLogger.logPickerChoiceInitGrantsCount(mInstanceId, android.os.Process.myUid(), + getPackageNameForUid(intentExtras), numberOfGrants); + + } + + /** + * Logs metrics for count of grants added for a package. + */ + public void logPickerChoiceAddedGrantsCount(int numberOfGrants, Bundle intentExtras) { + NonUiEventLogger.logPickerChoiceGrantsAdditionCount(mInstanceId, android.os.Process.myUid(), + getPackageNameForUid(intentExtras), numberOfGrants); + } + + /** + * Logs metrics for count of grants removed for a package. + */ + public void logPickerChoiceRevokedGrantsCount(int numberOfGrants, Bundle intentExtras) { + NonUiEventLogger.logPickerChoiceGrantsRemovedCount(mInstanceId, android.os.Process.myUid(), + getPackageNameForUid(intentExtras), numberOfGrants); + } + + /** + * Log metrics to notify that the banner is added to display in the recycler view grids + * @param bannerName the name of the banner added, + * refer {@link com.android.providers.media.photopicker.ui.TabAdapter.Banner} + */ + public void logBannerAdded(@NonNull String bannerName) { + mLogger.logBannerAdded(mInstanceId, bannerName); + } + + /** + * Log metrics to notify that the banner is dismissed by the user + */ + public void logBannerDismissed() { + mLogger.logBannerDismissed(mInstanceId); + } + + /** + * Log metrics to notify that the user clicked the banner action button + */ + public void logBannerActionButtonClicked() { + mLogger.logBannerActionButtonClicked(mInstanceId); + } + + /** + * Log metrics to notify that the user clicked on the remaining part of the banner + */ + public void logBannerClicked() { + mLogger.logBannerClicked(mInstanceId); + } + + @NonNull + private String getPackageNameForUid(Bundle extras) { + final int uid = extras.getInt(Intent.EXTRA_UID); + final PackageManager pm = mAppContext.getPackageManager(); + String[] packageNames = pm.getPackagesForUid(uid); + if (packageNames.length != 0) { + return packageNames[0]; + } + return new String(); + } + public InstanceId getInstanceId() { return mInstanceId; } @@ -560,23 +1312,18 @@ public class PickerViewModel extends AndroidViewModel { * * Show only the local features in the following cases - * 1. Photo Picker is launched by the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP} - * action for the permission flow. + * action for the permission flow. * 2. Photo Picker is launched with the {@link Intent#EXTRA_LOCAL_ONLY} as {@code true} in the - * {@link Intent#ACTION_GET_CONTENT} or {@link MediaStore#ACTION_PICK_IMAGES} action. + * {@link Intent#ACTION_GET_CONTENT} or {@link MediaStore#ACTION_PICK_IMAGES} action. * 3. Cloud Media in Photo picker is disabled, i.e., - * {@link ConfigStore#isCloudMediaInPhotoPickerEnabled()} is {@code false}. + * {@link ConfigStore#isCloudMediaInPhotoPickerEnabled()} is {@code false}. * * @return {@code true} iff either {@link #isUserSelectForApp()} or {@link #isLocalOnly()} is * {@code true}, OR if {@link ConfigStore#isCloudMediaInPhotoPickerEnabled()} is {@code false}. */ public boolean shouldShowOnlyLocalFeatures() { return isUserSelectForApp() || isLocalOnly() - || !getConfigStore().isCloudMediaInPhotoPickerEnabled(); - } - - @VisibleForTesting - protected ConfigStore getConfigStore() { - return MediaApplication.getConfigStore(); + || !mConfigStore.isCloudMediaInPhotoPickerEnabled(); } /** @@ -614,32 +1361,137 @@ public class PickerViewModel extends AndroidViewModel { /** * Dismiss (hide) the 'Choose App' banner for the current user. */ - @UiThread + @MainThread public void onUserDismissedChooseAppBanner() { + ThreadUtils.assertMainThread(); mBannerManager.onUserDismissedChooseAppBanner(); } /** * Dismiss (hide) the 'Cloud Media Available' banner for the current user. */ - @UiThread + @MainThread public void onUserDismissedCloudMediaAvailableBanner() { + ThreadUtils.assertMainThread(); mBannerManager.onUserDismissedCloudMediaAvailableBanner(); } /** * Dismiss (hide) the 'Account Updated' banner for the current user. */ - @UiThread + @MainThread public void onUserDismissedAccountUpdatedBanner() { + ThreadUtils.assertMainThread(); mBannerManager.onUserDismissedAccountUpdatedBanner(); } /** * Dismiss (hide) the 'Choose Account' banner for the current user. */ - @UiThread + @MainThread public void onUserDismissedChooseAccountBanner() { + ThreadUtils.assertMainThread(); mBannerManager.onUserDismissedChooseAccountBanner(); } + + /** + * @return a {@link LiveData} that posts Should Refresh Picker UI as {@code true} when notified. + */ + @NonNull + public LiveData<Boolean> shouldRefreshUiLiveData() { + return mShouldRefreshUiLiveData; + } + + private void registerRefreshUiNotificationObserver() { + mContentResolver = getContentResolverForSelectedUser(); + mContentResolver.registerContentObserver(REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, + /* notifyForDescendants */ false, mRefreshUiNotificationObserver); + } + + private void unregisterRefreshUiNotificationObserver() { + if (mContentResolver != null) { + mContentResolver.unregisterContentObserver(mRefreshUiNotificationObserver); + mContentResolver = null; + } + } + + private void resetRefreshUiNotificationObserver() { + unregisterRefreshUiNotificationObserver(); + registerRefreshUiNotificationObserver(); + } + + private ContentResolver getContentResolverForSelectedUser() { + final UserId selectedUserId = mUserIdManager.getCurrentUserProfileId(); + if (selectedUserId == null) { + Log.d(TAG, "Selected user id is NULL; returning the default content resolver."); + return mAppContext.getContentResolver(); + } + try { + return selectedUserId.getContentResolver(mAppContext); + } catch (PackageManager.NameNotFoundException e) { + Log.d(TAG, "Failed to get the content resolver for the selected user id " + + selectedUserId + "; returning the default content resolver.", e); + return mAppContext.getContentResolver(); + } + } + + public LiveData<Boolean> isSyncInProgress() { + return mIsSyncInProgress; + } + + /** + * Class used to store the result of the item modification operations. + */ + public class PaginatedItemsResult { + private List<Item> mItems = new ArrayList<>(); + + private int mAction = ACTION_DEFAULT; + + public PaginatedItemsResult(@NonNull List<Item> itemList, + @ItemsAction.Type int action) { + mItems = itemList; + mAction = action; + } + + public List<Item> getItems() { + return mItems; + } + + @ItemsAction.Type + public int getAction() { + return mAction; + } + } + + /** + * This will inform the media Provider process that the UI is preparing to load data for the + * main photos grid. + */ + public void initPhotoPickerData() { + initPhotoPickerData(Category.DEFAULT); + } + + /** + * This will inform the media Provider process that the UI is preparing to load data for main + * photos grid or album contents grid. + */ + public void initPhotoPickerData(@NonNull Category category) { + if (mConfigStore.isCloudMediaInPhotoPickerEnabled()) { + UserId userId = mUserIdManager.getCurrentUserProfileId(); + DataLoaderThread.getHandler().postDelayed(() -> { + if (category == Category.DEFAULT) { + mIsSyncInProgress.postValue(true); + } + mItemsProvider.initPhotoPickerData(category.getId(), + category.getAuthority(), + shouldShowOnlyLocalFeatures(), + userId); + }, TOKEN, DELAY_MILLIS); + } + } + + private void clearQueuedTasksInDataLoaderThread() { + DataLoaderThread.getHandler().removeCallbacksAndMessages(TOKEN); + DataLoaderThread.getHandler().removeCallbacksAndMessages(mLoadCategoryItemsThreadToken); + } } diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java index 55a4a813e..d70d9efdc 100644 --- a/src/com/android/providers/media/scan/ModernMediaScanner.java +++ b/src/com/android/providers/media/scan/ModernMediaScanner.java @@ -48,6 +48,7 @@ import static android.provider.MediaStore.UNKNOWN_STRING; import static android.text.format.DateUtils.HOUR_IN_MILLIS; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; +import static com.android.providers.media.util.FileUtils.canonicalize; import static com.android.providers.media.util.Metrics.translateReason; import static java.util.Objects.requireNonNull; @@ -121,6 +122,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -210,7 +212,7 @@ public class ModernMediaScanner implements MediaScanner { * overlap and confuse each other. */ @GuardedBy("mDirectoryLocks") - private final Map<Path, DirectoryLock> mDirectoryLocks = new ArrayMap<>(); + private final Map<String, DirectoryLock> mDirectoryLocks = new ArrayMap<>(); /** * Set of MIME types that should be considered to be DRM, meaning we need to @@ -242,7 +244,7 @@ public class ModernMediaScanner implements MediaScanner { public void scanDirectory(@NonNull File file, @ScanReason int reason) { requireNonNull(file); try { - file = file.getCanonicalFile(); + file = canonicalize(file); } catch (IOException e) { Log.e(TAG, "Couldn't canonicalize directory to scan" + file, e); return; @@ -262,7 +264,7 @@ public class ModernMediaScanner implements MediaScanner { public Uri scanFile(@NonNull File file, @ScanReason int reason) { requireNonNull(file); try { - file = file.getCanonicalFile(); + file = canonicalize(file); } catch (IOException e) { Log.e(TAG, "Couldn't canonicalize file to scan" + file, e); return null; @@ -306,14 +308,14 @@ public class ModernMediaScanner implements MediaScanner { public void onDirectoryDirty(@NonNull File dir) { requireNonNull(dir); try { - dir = dir.getCanonicalFile(); + dir = canonicalize(dir); } catch (IOException e) { Log.e(TAG, "Couldn't canonicalize directory" + dir, e); return; } synchronized (mPendingCleanDirectories) { - mPendingCleanDirectories.remove(dir.getPath()); + mPendingCleanDirectories.remove(dir.getPath().toLowerCase(Locale.ROOT)); FileUtils.setDirectoryDirty(dir, /* isDirty */ true); } } @@ -349,7 +351,7 @@ public class ModernMediaScanner implements MediaScanner { private final long mStartGeneration; private final boolean mSingleFile; - private final Set<Path> mAcquiredDirectoryLocks = new ArraySet<>(); + private final Set<String> mAcquiredDirectoryLocks = new ArraySet<>(); private final ArrayList<ContentProviderOperation> mPending = new ArrayList<>(); private final LongArray mScannedIds = new LongArray(); private final LongArray mUnknownIds = new LongArray(); @@ -448,7 +450,7 @@ public class ModernMediaScanner implements MediaScanner { mHiddenDirCount++; } if (mSingleFile) { - acquireDirectoryLock(mRoot.getParentFile().toPath()); + acquireDirectoryLock(mRoot.getParentFile().toPath().toString()); } try { Files.walkFileTree(mRoot.toPath(), this); @@ -458,7 +460,7 @@ public class ModernMediaScanner implements MediaScanner { throw new IllegalStateException(e); } finally { if (mSingleFile) { - releaseDirectoryLock(mRoot.getParentFile().toPath()); + releaseDirectoryLock(mRoot.getParentFile().toPath().toString()); } Trace.endSection(); } @@ -639,19 +641,20 @@ public class ModernMediaScanner implements MediaScanner { * thread exclusive access to ensure that parallel scans don't overlap * and confuse each other. */ - private void acquireDirectoryLock(@NonNull Path dir) { + private void acquireDirectoryLock(@NonNull String dirPath) { Trace.beginSection("Scanner.acquireDirectoryLock"); DirectoryLock lock; + final String dirLower = dirPath.toLowerCase(Locale.ROOT); synchronized (mDirectoryLocks) { - lock = mDirectoryLocks.get(dir); + lock = mDirectoryLocks.get(dirLower); if (lock == null) { lock = new DirectoryLock(); - mDirectoryLocks.put(dir, lock); + mDirectoryLocks.put(dirLower, lock); } lock.count++; } lock.lock.lock(); - mAcquiredDirectoryLocks.add(dir); + mAcquiredDirectoryLocks.add(dirLower); Trace.endSection(); } @@ -660,20 +663,21 @@ public class ModernMediaScanner implements MediaScanner { * other waiting parallel scans to proceed, and cleaning up data * structures if no other threads are waiting. */ - private void releaseDirectoryLock(@NonNull Path dir) { + private void releaseDirectoryLock(@NonNull String dirPath) { Trace.beginSection("Scanner.releaseDirectoryLock"); DirectoryLock lock; + final String dirLower = dirPath.toLowerCase(Locale.ROOT); synchronized (mDirectoryLocks) { - lock = mDirectoryLocks.get(dir); + lock = mDirectoryLocks.get(dirLower); if (lock == null) { throw new IllegalStateException(); } if (--lock.count == 0) { - mDirectoryLocks.remove(dir); + mDirectoryLocks.remove(dirLower); } } lock.lock.unlock(); - mAcquiredDirectoryLocks.remove(dir); + mAcquiredDirectoryLocks.remove(dirLower); Trace.endSection(); } @@ -682,8 +686,8 @@ public class ModernMediaScanner implements MediaScanner { // Release any locks we're still holding, typically when we // encountered an exception; we snapshot the original list so we're // not confused as it's mutated by release operations - for (Path dir : new ArraySet<>(mAcquiredDirectoryLocks)) { - releaseDirectoryLock(dir); + for (String dirPath : new ArraySet<>(mAcquiredDirectoryLocks)) { + releaseDirectoryLock(dirPath); } mClient.close(); @@ -709,11 +713,11 @@ public class ModernMediaScanner implements MediaScanner { // This removes additional dirty state check for subdirectories of nomedia // directory. mIsDirectoryTreeDirty = true; - mPendingCleanDirectories.add(dir.toFile().getPath()); + mPendingCleanDirectories.add(dir.toFile().getPath().toLowerCase(Locale.ROOT)); } else { Log.d(TAG, "Skipping preVisitDirectory " + dir.toFile()); if (mExcludeDirs.size() <= MAX_EXCLUDE_DIRS) { - mExcludeDirs.add(dir.toFile().getPath()); + mExcludeDirs.add(dir.toFile().getPath().toLowerCase(Locale.ROOT)); return FileVisitResult.SKIP_SUBTREE; } else { Log.w(TAG, "ExcludeDir size exceeded, not skipping preVisitDirectory " @@ -724,7 +728,7 @@ public class ModernMediaScanner implements MediaScanner { // Acquire lock on this directory to ensure parallel scans don't // overlap and confuse each other - acquireDirectoryLock(dir); + acquireDirectoryLock(dir.toString()); if (FileUtils.isDirectoryHidden(dir.toFile())) { mHiddenDirCount++; @@ -920,11 +924,12 @@ public class ModernMediaScanner implements MediaScanner { // Now that we're finished scanning this directory, release lock to // allow other parallel scans to proceed - releaseDirectoryLock(dir); + releaseDirectoryLock(dir.toString()); if (mIsDirectoryTreeDirty) { synchronized (mPendingCleanDirectories) { - if (mPendingCleanDirectories.remove(dir.toFile().getPath())) { + if (mPendingCleanDirectories.remove( + dir.toFile().getPath().toLowerCase(Locale.ROOT))) { // If |dir| is still clean, then persist FileUtils.setDirectoryDirty(dir.toFile(), false /* isDirty */); mIsDirectoryTreeDirty = false; @@ -1746,6 +1751,6 @@ public class ModernMediaScanner implements MediaScanner { } static void logTroubleScanning(@NonNull File file, @NonNull Exception e) { - if (LOGW) Log.w(TAG, "Trouble scanning " + file + ": " + e); + if (LOGW) Log.w(TAG, "Trouble scanning " + file, e); } } diff --git a/src/com/android/providers/media/scan/NullMediaScanner.java b/src/com/android/providers/media/scan/NullMediaScanner.java deleted file mode 100644 index fc475c700..000000000 --- a/src/com/android/providers/media/scan/NullMediaScanner.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.providers.media.scan; - -import android.content.Context; -import android.net.Uri; -import android.provider.MediaStore; -import android.util.Log; - -import com.android.providers.media.MediaVolume; - -import java.io.File; - -/** - * Null scanner that ignores all scanning requests. Can be useful when running - * as {@link MediaStore#AUTHORITY_LEGACY} or during unit tests. - */ -public class NullMediaScanner implements MediaScanner { - private static final String TAG = "NullMediaScanner"; - - private final Context mContext; - - public NullMediaScanner(Context context) { - mContext = context; - } - - @Override - public Context getContext() { - return mContext; - } - - @Override - public void scanDirectory(File file, int reason) { - Log.w(TAG, "Ignoring scan request for " + file); - } - - @Override - public Uri scanFile(File file, int reason) { - Log.w(TAG, "Ignoring scan request for " + file); - return null; - } - - @Override - public void onDetachVolume(MediaVolume volume) { - // Ignored - } - - @Override - public void onIdleScanStopped() { - // Ignored - } - - @Override - public void onDirectoryDirty(File file) { - // Ignored - } -} diff --git a/src/com/android/providers/media/stableuris/dao/BackupIdRow.java b/src/com/android/providers/media/stableuris/dao/BackupIdRow.java index 27060d5d7..0bd03906d 100644 --- a/src/com/android/providers/media/stableuris/dao/BackupIdRow.java +++ b/src/com/android/providers/media/stableuris/dao/BackupIdRow.java @@ -20,13 +20,8 @@ import android.provider.MediaStore.MediaColumns; import com.android.providers.media.util.StringUtils; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; import java.io.Serializable; -import java.util.Base64; import java.util.Objects; /** @@ -248,24 +243,35 @@ public final class BackupIdRow implements Serializable { /** * Serializes the given {@link BackupIdRow} object to a string + * Format is + * "is_dirty::_id::is_fav::is_pending::is_trashed::media_type::user_id::owner_id::date_expires" */ public static String serialize(BackupIdRow backupIdRow) throws IOException { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream); - objectOutputStream.writeObject(backupIdRow); - objectOutputStream.close(); - return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()); + return String.format("%s::%s::%s::%s::%s::%s::%s::%s::%s", + backupIdRow.getIsDirty() ? "1" : "0", backupIdRow.getId(), + backupIdRow.getIsFavorite(), backupIdRow.getIsPending(), backupIdRow.getIsTrashed(), + backupIdRow.getMediaType(), backupIdRow.getUserId(), + backupIdRow.getOwnerPackageId(), backupIdRow.getDateExpires()); } /** * Deserializes the given string to {@link BackupIdRow} object */ public static BackupIdRow deserialize(String s) throws IOException, ClassNotFoundException { - byte[] bytes = Base64.getDecoder().decode(s); - ObjectInputStream objectInputStream = new ObjectInputStream( - new ByteArrayInputStream(bytes)); - BackupIdRow backupIdRow = (BackupIdRow) objectInputStream.readObject(); - objectInputStream.close(); - return backupIdRow; + if (s == null || s.isEmpty()) { + return null; + } + + String[] fields = s.split("::"); + BackupIdRow.Builder builder = BackupIdRow.newBuilder(Long.parseLong(fields[1])); + builder.setIsDirty(Objects.equals(fields[0], "1")); + builder.setIsFavorite(Integer.parseInt(fields[2])); + builder.setIsPending(Integer.parseInt(fields[3])); + builder.setIsTrashed(Integer.parseInt(fields[4])); + builder.setMediaType(Integer.parseInt(fields[5])); + builder.setUserId(Integer.parseInt(fields[6])); + builder.setOwnerPackagedId(Integer.parseInt(fields[7])); + builder.setDateExpires(fields[8]); + return builder.build(); } } diff --git a/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceService.java b/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceService.java index 493333f25..d7adaa45c 100644 --- a/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceService.java +++ b/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceService.java @@ -72,7 +72,7 @@ public class StableUriIdleMaintenanceService extends JobService { final JobInfo job = new JobInfo.Builder(IDLE_JOB_ID, new ComponentName(context, StableUriIdleMaintenanceService.class)) - .setPeriodic(TimeUnit.DAYS.toMillis(7)) + .setPeriodic(TimeUnit.DAYS.toMillis(3)) .setRequiresCharging(true) .setRequiresDeviceIdle(true) .build(); diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java index 00504f4c7..1f927f35d 100644 --- a/src/com/android/providers/media/util/FileUtils.java +++ b/src/com/android/providers/media/util/FileUtils.java @@ -1740,7 +1740,7 @@ public class FileUtils { // Returns true If .nomedia file is empty or content doesn't match |dir| // Returns false otherwise return !expectedPath.isPresent() - || !expectedPath.get().equals(dir.getPath()); + || !expectedPath.get().equalsIgnoreCase(dir.getPath()); } catch (IOException e) { Log.w(TAG, "Failed to read directory dirty" + dir); return true; @@ -1849,4 +1849,16 @@ public class FileUtils { Objects.requireNonNull(path); return new File(path).getCanonicalPath(); } + + /** + * A wrapper for {@link File#getCanonicalFile()} that catches {@link IOException}-s and + * re-throws them as {@link RuntimeException}-s. + * + * @see File#getCanonicalFile() + */ + @NonNull + public static File canonicalize(@NonNull File file) throws IOException { + Objects.requireNonNull(file); + return file.getCanonicalFile(); + } } diff --git a/src/com/android/providers/media/util/SpecialFormatDetector.java b/src/com/android/providers/media/util/SpecialFormatDetector.java index a8569d746..23a5a43d3 100644 --- a/src/com/android/providers/media/util/SpecialFormatDetector.java +++ b/src/com/android/providers/media/util/SpecialFormatDetector.java @@ -110,11 +110,10 @@ public class SpecialFormatDetector { bitmapOptions.inJustDecodeBounds = true; BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOptions); - if (bitmapOptions.outMimeType.equalsIgnoreCase("image/gif")) { + if ("image/gif".equalsIgnoreCase(bitmapOptions.outMimeType)) { return FileColumns._SPECIAL_FORMAT_GIF; } - if (bitmapOptions.outMimeType.equalsIgnoreCase("image/webp") && - isAnimatedWebp(file)) { + if ("image/webp".equalsIgnoreCase(bitmapOptions.outMimeType) && isAnimatedWebp(file)) { return FileColumns._SPECIAL_FORMAT_ANIMATED_WEBP; } return FileColumns._SPECIAL_FORMAT_NONE; diff --git a/src/com/android/providers/media/util/UserCache.java b/src/com/android/providers/media/util/UserCache.java index fa467793b..ed3e91f78 100644 --- a/src/com/android/providers/media/util/UserCache.java +++ b/src/com/android/providers/media/util/UserCache.java @@ -18,6 +18,7 @@ package com.android.providers.media.util; import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE; import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE; + import static com.android.providers.media.util.Logging.TAG; import android.annotation.SuppressLint; @@ -111,8 +112,7 @@ public class UserCache { private boolean isUnlockedAndMediaSharedWithParent(@NonNull UserHandle profile) { Context userContext = getContextForUser(profile); UserManager userManager = userContext.getSystemService(UserManager.class); - return (SdkLevel.isAtLeastT() ? - userManager.isUserUnlocked() : userManager.isUserUnlocked(profile)) + return userManager.isUserUnlockingOrUnlocked(profile) && userManager.isMediaSharedWithParent(); } diff --git a/tests/Android.bp b/tests/Android.bp index 725b722da..7fb7a7c26 100644 --- a/tests/Android.bp +++ b/tests/Android.bp @@ -191,9 +191,12 @@ android_test { "glide-gifdecoder-prebuilt", "glide-disklrucache-prebuilt", "glide-annotation-and-compiler-prebuilt", + "glide-integration-recyclerview-prebuilt", "androidx.fragment_fragment", "androidx.vectordrawable_vectordrawable-animated", "androidx.exifinterface_exifinterface", + "androidx.work_work-runtime", + "androidx.work_work-testing", "exoplayer-mediaprovider-ui", "SettingsLibProfileSelector", "SettingsLibSelectorWithWidgetPreference", @@ -211,12 +214,13 @@ android_test { }, data: [ - ":MediaProviderTestAppWithStoragePerms", - ":MediaProviderTestAppWithMediaPerms", - ":MediaProviderTestAppWithoutPerms", + ":LegacyMediaProviderTestApp", ":MediaProviderTestAppForPermissionActivity", ":MediaProviderTestAppForPermissionActivity33", - ":LegacyMediaProviderTestApp", + ":MediaProviderTestAppWithMediaPerms", + ":MediaProviderTestAppWithStoragePerms", + ":MediaProviderTestAppWithoutPerms", + ":MediaProviderTestAppWithUserSelectedPerms", ], per_testcase_directory: true, @@ -227,3 +231,10 @@ filegroup { name: "mediaprovider-testutils", srcs: ["utils/**/*.java"], } + +filegroup { + name: "mediaprovider-library", + srcs: [ + "src/com/android/providers/media/library/RunOnlyOnPostsubmit.java", + ], +} diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml index b03be2c7b..713227692 100644 --- a/tests/AndroidManifest.xml +++ b/tests/AndroidManifest.xml @@ -55,10 +55,33 @@ </intent-filter> </activity> + <!-- Intent Action "android.intent.action.MAIN" + + This intent action is used to start the activity as a main entry point, does not expect + to receive data. + + {@link androidx.test.core.app.ActivityScenario#launchActivityForResult(Class)} launches + the activity with the intent action {@link android.content.Intent#ACTION_MAIN}. + --> + <activity android:name="com.android.providers.media.photopicker.espresso.PhotoPickerAccessibilityDisabledTestActivity"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + </intent-filter> + </activity> + <provider android:name="com.android.providers.media.photopicker.LocalProvider" android:authorities="com.android.providers.media.photopicker.tests.local" android:exported="false" /> + <provider android:name="com.android.providers.media.cloudproviders.FlakyCloudProvider" + android:authorities="com.android.providers.media.photopicker.tests.cloud_flaky" + android:permission="com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS" + android:exported="true"> + <intent-filter> + <action android:name="android.content.action.CLOUD_MEDIA_PROVIDER" /> + </intent-filter> + </provider> + <provider android:name="com.android.providers.media.cloudproviders.CloudProviderPrimary" android:authorities="com.android.providers.media.photopicker.tests.cloud_primary" android:permission="com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS" @@ -90,6 +113,17 @@ android:authorities="com.android.providers.media.photopicker.tests.cloud_no_intent_filter" android:exported="true"> </provider> + + <service + android:name= + "com.android.providers.media.stableuris.job.StableUriIdleMaintenanceService" + android:exported="true" + android:permission="android.permission.BIND_JOB_SERVICE" /> + + <service + android:name="com.android.providers.media.IdleService" + android:exported="true" + android:permission="android.permission.BIND_JOB_SERVICE" /> </application> <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner" diff --git a/tests/client/Android.bp b/tests/client/Android.bp index 1b3959f67..771e05eec 100644 --- a/tests/client/Android.bp +++ b/tests/client/Android.bp @@ -16,6 +16,7 @@ android_test { srcs: [ "src/**/*.java", ":mediaprovider-testutils", + ":mediaprovider-library", ], libs: [ diff --git a/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java index e9b9cbf26..dc86aae55 100644 --- a/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java +++ b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java @@ -41,6 +41,8 @@ import android.util.Pair; import androidx.test.InstrumentationRegistry; +import com.android.providers.media.library.RunOnlyOnPostsubmit; + import org.junit.After; import org.junit.Assume; import org.junit.Before; @@ -63,6 +65,7 @@ import java.util.concurrent.TimeUnit; * external client app. Exercises all supported playlist formats. */ @RunWith(Parameterized.class) +@RunOnlyOnPostsubmit public class ClientPlaylistTest { private static final String TAG = "ClientPlaylistTest"; diff --git a/tests/client/src/com/android/providers/media/client/PerformanceTest.java b/tests/client/src/com/android/providers/media/client/PerformanceTest.java index b792ee22c..f7f5c27ad 100644 --- a/tests/client/src/com/android/providers/media/client/PerformanceTest.java +++ b/tests/client/src/com/android/providers/media/client/PerformanceTest.java @@ -36,6 +36,7 @@ import androidx.test.filters.LargeTest; import androidx.test.runner.AndroidJUnit4; import androidx.test.uiautomator.UiDevice; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.tests.utils.Timer; import org.junit.Test; @@ -62,6 +63,7 @@ import java.util.concurrent.TimeUnit; */ @RunWith(AndroidJUnit4.class) @LargeTest +@RunOnlyOnPostsubmit public class PerformanceTest { private static final String TAG = "PerformanceTest"; diff --git a/tests/client/src/com/android/providers/media/client/PlaylistPerformanceTest.java b/tests/client/src/com/android/providers/media/client/PlaylistPerformanceTest.java index e76181420..c7739785d 100644 --- a/tests/client/src/com/android/providers/media/client/PlaylistPerformanceTest.java +++ b/tests/client/src/com/android/providers/media/client/PlaylistPerformanceTest.java @@ -37,6 +37,7 @@ import androidx.annotation.NonNull; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.tests.utils.Timer; import org.junit.After; @@ -49,6 +50,7 @@ import java.io.IOException; import java.io.OutputStream; @RunWith(AndroidJUnit4.class) +@RunOnlyOnPostsubmit public class PlaylistPerformanceTest { private static final Uri AUDIO_URI = MediaStore.Audio.Media .getContentUri(VOLUME_EXTERNAL_PRIMARY); diff --git a/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java b/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java index af17d50c9..6f0d33dd4 100644 --- a/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java +++ b/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java @@ -40,6 +40,8 @@ import android.provider.MediaStore; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; + import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Ignore; @@ -49,6 +51,7 @@ import org.junit.runner.RunWith; import java.io.OutputStream; @RunWith(AndroidJUnit4.class) +@RunOnlyOnPostsubmit public class PublicVolumePlaylistTest { @BeforeClass public static void setUp() throws Exception { diff --git a/tests/client/src/com/android/providers/media/client/PublicVolumeTest.java b/tests/client/src/com/android/providers/media/client/PublicVolumeTest.java index e5181d5b0..da80f4181 100644 --- a/tests/client/src/com/android/providers/media/client/PublicVolumeTest.java +++ b/tests/client/src/com/android/providers/media/client/PublicVolumeTest.java @@ -36,6 +36,8 @@ import android.provider.MediaStore; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; + import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Ignore; @@ -45,6 +47,7 @@ import org.junit.runner.RunWith; import java.io.OutputStream; @RunWith(AndroidJUnit4.class) +@RunOnlyOnPostsubmit public class PublicVolumeTest { @BeforeClass public static void setUp() throws Exception { diff --git a/tests/src/com/android/providers/media/ConfigStoreTest.java b/tests/src/com/android/providers/media/ConfigStoreTest.java new file mode 100644 index 000000000..10728b83a --- /dev/null +++ b/tests/src/com/android/providers/media/ConfigStoreTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; +import java.util.concurrent.Executor; + +/** + * Verifies ConfigStore default values. + */ +@RunWith(AndroidJUnit4.class) +public class ConfigStoreTest { + ConfigStore mConfigStore = new ConfigStore() { + @NonNull + @Override + public List<String> getTranscodeCompatManifest() { + return null; + } + + @NonNull + @Override + public List<String> getTranscodeCompatStale() { + return null; + } + + @Override + public void addOnChangeListener(@NonNull Executor executor, + @NonNull Runnable listener) { + } + }; + + @Test + public void test_defaultValueConfigStore_allCorrect() { + assertTrue(mConfigStore.getAllowedCloudProviderPackages().isEmpty()); + assertNull(mConfigStore.getDefaultCloudProviderPackage()); + assertEquals(60000, mConfigStore.getTranscodeMaxDurationMs()); + assertTrue(mConfigStore.isCloudMediaInPhotoPickerEnabled()); + assertFalse(mConfigStore.isGetContentTakeOverEnabled()); + assertTrue(mConfigStore.isPickerChoiceManagedSelectionEnabled()); + assertFalse(mConfigStore.isStableUrisForExternalVolumeEnabled()); + assertFalse(mConfigStore.isStableUrisForInternalVolumeEnabled()); + assertTrue(mConfigStore.isTranscodeEnabled()); + assertTrue(mConfigStore.isUserSelectForAppEnabled()); + assertTrue(mConfigStore.shouldEnforceCloudProviderAllowlist()); + assertTrue(mConfigStore.shouldPickerPreloadForGetContent()); + assertTrue(mConfigStore.shouldPickerPreloadForPickImages()); + assertFalse(mConfigStore.shouldPickerRespectPreloadArgumentForPickImages()); + assertFalse(mConfigStore.shouldTranscodeDefault()); + } +} diff --git a/tests/src/com/android/providers/media/DatabaseBackupAndRecoveryTest.java b/tests/src/com/android/providers/media/DatabaseBackupAndRecoveryTest.java new file mode 100644 index 000000000..6b459933b --- /dev/null +++ b/tests/src/com/android/providers/media/DatabaseBackupAndRecoveryTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media; + +import static com.android.providers.media.DatabaseHelper.INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY; +import static com.android.providers.media.DatabaseHelper.INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX; +import static com.android.providers.media.DatabaseHelper.INTERNAL_DB_SESSION_ID_XATTR_KEY; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RunWith(AndroidJUnit4.class) +public class DatabaseBackupAndRecoveryTest { + + @Test + public void testXattrOperations() { + final Context context = ApplicationProvider.getApplicationContext(); + final String path = context.getFilesDir().getPath(); + final Integer value = 1000000; + final String sessionId = UUID.randomUUID().toString(); + DatabaseBackupAndRecovery.setXattr(path, INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY, + String.valueOf(value)); + DatabaseBackupAndRecovery.setXattr(path, INTERNAL_DB_SESSION_ID_XATTR_KEY, sessionId); + + assertTrue(DatabaseBackupAndRecovery.listXattr(path).containsAll(Arrays.asList( + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY, INTERNAL_DB_SESSION_ID_XATTR_KEY))); + Optional<Integer> actualIntegerValue = DatabaseBackupAndRecovery.getXattrOfIntegerValue( + path, + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY); + assertTrue(actualIntegerValue.isPresent()); + assertThat(actualIntegerValue.get()).isEqualTo(value); + Optional<String> actualStringValue = DatabaseBackupAndRecovery.getXattr(path, + INTERNAL_DB_SESSION_ID_XATTR_KEY); + assertTrue(actualStringValue.isPresent()); + + DatabaseBackupAndRecovery.removeXattr(path, INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY); + DatabaseBackupAndRecovery.removeXattr(path, INTERNAL_DB_SESSION_ID_XATTR_KEY); + } + + @Test + public void testGetInvalidUsersList() { + List<String> xattrData = Arrays.asList( + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX + "0", + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX + "10", + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX + "11", + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX + "12", + INTERNAL_DB_NEXT_ROW_ID_XATTR_KEY_PREFIX + "13"); + + assertThat(DatabaseBackupAndRecovery.getInvalidUsersList(xattrData, /* validUserIds */ + Arrays.asList("0", "13"))).containsExactly("10", "11", "12"); + } +} diff --git a/tests/src/com/android/providers/media/IdleServiceTest.java b/tests/src/com/android/providers/media/IdleServiceTest.java index 36d8d35e0..bd0e7bd18 100644 --- a/tests/src/com/android/providers/media/IdleServiceTest.java +++ b/tests/src/com/android/providers/media/IdleServiceTest.java @@ -29,9 +29,15 @@ import static android.provider.MediaStore.MediaColumns.RELATIVE_PATH; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; import android.Manifest; +import android.app.Instrumentation; +import android.app.job.JobScheduler; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.ContentUris; @@ -42,14 +48,21 @@ import android.net.Uri; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Environment; +import android.os.NewUserRequest; import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.os.UserHandle; +import android.os.UserManager; import android.provider.MediaStore; import android.text.format.DateUtils; import android.util.Log; import androidx.test.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.filters.SdkSuppress; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.scan.MediaScannerTest; import com.android.providers.media.util.FileUtils; @@ -65,11 +78,15 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; @RunWith(AndroidJUnit4.class) public class IdleServiceTest { private static final String TAG = MediaProviderTest.TAG; + private static final int IDLE_JOB_ID = -200; private File mDir; @@ -81,6 +98,8 @@ public class IdleServiceTest { android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG, android.Manifest.permission.READ_DEVICE_CONFIG, Manifest.permission.INTERACT_ACROSS_USERS, + Manifest.permission.MANAGE_USERS, + Manifest.permission.WRITE_MEDIA_STORAGE, android.Manifest.permission.DUMP); mDir = new File(context.getExternalMediaDirs()[0], "test_" + System.nanoTime()); @@ -123,28 +142,37 @@ public class IdleServiceTest { final File d = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", "random.bin")); final File e = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", ".nomedia")); - // Idle maintenance pass should clean up unknown files - MediaStore.runIdleMaintenance(resolver); - assertFalse(exists(a)); - assertFalse(exists(b)); - assertTrue(exists(c)); - assertFalse(exists(d)); - assertTrue(exists(e)); - - // And change the UUID, which emulates ejecting and mounting a different - // storage device; all thumbnails should then be invalidated - final File uuidFile = buildPath(dir, Environment.DIRECTORY_PICTURES, - ".thumbnails", ".database_uuid"); - delete(uuidFile); - touch(uuidFile); - - // Idle maintenance pass should clean up all files except .nomedia file - MediaStore.runIdleMaintenance(resolver); - assertFalse(exists(a)); - assertFalse(exists(b)); - assertFalse(exists(c)); - assertFalse(exists(d)); - assertTrue(exists(e)); + try { + // Idle maintenance pass should clean up unknown files + MediaStore.runIdleMaintenance(resolver); + assertFalse(exists(a)); + assertFalse(exists(b)); + assertTrue(exists(c)); + assertFalse(exists(d)); + assertTrue(exists(e)); + + // And change the UUID, which emulates ejecting and mounting a different + // storage device; all thumbnails should then be invalidated + final File uuidFile = buildPath(dir, Environment.DIRECTORY_PICTURES, + ".thumbnails", ".database_uuid"); + delete(uuidFile); + touch(uuidFile); + + // Idle maintenance pass should clean up all files except .nomedia file + MediaStore.runIdleMaintenance(resolver); + assertFalse("File a should have been deleted", exists(a)); + assertFalse("File b should have been deleted", exists(b)); + assertFalse("File c should have been deleted", exists(c)); + assertFalse("File d should have been deleted", exists(d)); + assertTrue("File e should have existed", exists(e)); + delete(uuidFile); + } finally { + a.delete(); + b.delete(); + c.delete(); + d.delete(); + e.delete(); + } } /** @@ -168,9 +196,9 @@ public class IdleServiceTest { MediaStore.runIdleMaintenance(resolver); - assertExpiredItemIsExtended(resolver, uri1); - assertExpiredItemIsExtended(resolver, uri2); - assertExpiredItemIsExtended(resolver, uri3); + assertExpiredItemIsExtended(resolver, uri1, dateExpires1); + assertExpiredItemIsExtended(resolver, uri2, dateExpires2); + assertExpiredItemIsExtended(resolver, uri3, dateExpires3); } @Test @@ -182,7 +210,7 @@ public class IdleServiceTest { MediaStore.runIdleMaintenance(resolver); - assertExpiredItemIsExtended(resolver, uri); + assertExpiredItemIsExtended(resolver, uri, dateExpires); } @Test @@ -257,9 +285,145 @@ public class IdleServiceTest { } } - private void assertExpiredItemIsExtended(ContentResolver resolver, Uri uri) throws Exception { - final long expectedExtendedTimestamp = - (System.currentTimeMillis() + FileUtils.DEFAULT_DURATION_EXTENDED) / 1000 - 1; + @Test + public void testJobScheduling() { + try { + final Context context = InstrumentationRegistry.getTargetContext(); + final JobScheduler scheduler = InstrumentationRegistry.getTargetContext() + .getSystemService(JobScheduler.class); + cancelJob(); + assertNull(scheduler.getPendingJob(IDLE_JOB_ID)); + + IdleService.scheduleIdlePass(context); + assertNotNull(scheduler.getPendingJob(IDLE_JOB_ID)); + } finally { + cancelJob(); + } + } + + private void cancelJob() { + final JobScheduler scheduler = InstrumentationRegistry.getTargetContext() + .getSystemService(JobScheduler.class); + if (scheduler.getPendingJob(IDLE_JOB_ID) != null) { + scheduler.cancel(IDLE_JOB_ID); + } + } + + /** + * Idle maintenance run on non-demo devices should not remove xattr data stored for different + * users on /data/media/0. This is not done due to b/305658663. + */ + @Test + @RunOnlyOnPostsubmit + @LargeTest + @SdkSuppress(minSdkVersion = 33, codeName = "T") + public void test_idle_maintenance_nonDemoDevice() throws IOException { + assumeTrue(UserManager.supportsMultipleUsers()); + InstrumentationRegistry.getInstrumentation().getUiAutomation() + .adoptShellPermissionIdentity( + Manifest.permission.INTERACT_ACROSS_USERS, + Manifest.permission.CREATE_USERS, + Manifest.permission.MANAGE_USERS, + Manifest.permission.WRITE_MEDIA_STORAGE, + android.Manifest.permission.DUMP); + SystemClock.sleep(3000); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + Context context = instrumentation.getContext(); + UserManager userManager = context.getSystemService(UserManager.class); + Integer secondaryUser = -1; + boolean secondaryUserPresent = false; + + try { + secondaryUser = createNewUser(userManager); + secondaryUserPresent = true; + startUser(secondaryUser); + SystemClock.sleep(3000); + + // Verify presence of recovery data + String[] recoveryData = MediaStore.getRecoveryData(context.getContentResolver()); + assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn( + Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser)); + + executeShellCommand( + "content call --uri content://media/external/file --method " + + "run_idle_maintenance --user " + + UserHandle.SYSTEM.getIdentifier()); + executeShellCommand( + "content call --uri content://media/external/file --method " + + "run_idle_maintenance --user " + secondaryUser); + + // Verify presence of recovery data even after running idle maintenance + recoveryData = MediaStore.getRecoveryData(context.getContentResolver()); + assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn( + Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser)); + + // Remove secondary user + removeUser(secondaryUser); + secondaryUserPresent = false; + SystemClock.sleep(3000); + + // Run idle maintenance for user 0 + executeShellCommand( + "content call --uri content://media/external/file --method " + + "run_idle_maintenance --user " + + UserHandle.SYSTEM.getIdentifier()); + + // Verify presence of recovery data + recoveryData = MediaStore.getRecoveryData(context.getContentResolver()); + assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn( + Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser)); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + if (secondaryUserPresent) { + removeUser(secondaryUser); + } + MediaStore.removeRecoveryData(context.getContentResolver()); + } + } + + private Set<Integer> getUserIdsForUsersFromRecoveryData(String[] recoveryData) { + Set<Integer> userIdSet = new HashSet<>(); + for (String data : recoveryData) { + if (data.startsWith("user.extdbnextrowid")) { + userIdSet.add(Integer.valueOf(data.substring("user.extdbnextrowid".length()))); + } else if (data.startsWith("user.extdbsessionid")) { + userIdSet.add(Integer.valueOf(data.substring("user.extdbsessionid".length()))); + } + } + + return userIdSet; + } + + + private int createNewUser(UserManager userManager) { + final NewUserRequest newUserRequest = new NewUserRequest.Builder().setName( + "test_user" + System.currentTimeMillis()).setUserType( + UserManager.USER_TYPE_FULL_SECONDARY).build(); + final UserHandle newUser = userManager.createUser(newUserRequest).getUser(); + if (newUser == null) { + fail("Error while creating a new user"); + } + return newUser.getIdentifier(); + } + + private void startUser(int userId) throws IOException { + Log.i(TAG, "Starting user " + userId); + String output = executeShellCommand("am start-user -w " + userId); + if (output.startsWith("Error")) { + fail(String.format("Failed to start user %d: %s", userId, output)); + } + } + + private void removeUser(int userId) throws IOException { + final String output = executeShellCommand("cmd package remove-user " + userId); + if (output.startsWith("Error")) { + fail("Error removing the user #" + userId + ": " + output); + } + } + + private void assertExpiredItemIsExtended(ContentResolver resolver, Uri uri, + long lastExpiredDate) { final String[] projection = new String[]{DATE_EXPIRES}; final Bundle queryArgs = new Bundle(); queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE); @@ -268,10 +432,17 @@ public class IdleServiceTest { assertThat(cursor.getCount()).isEqualTo(1); cursor.moveToFirst(); final long dateExpiresAfter = cursor.getLong(0); - assertThat(dateExpiresAfter).isGreaterThan(expectedExtendedTimestamp); + assertThat(dateExpiresAfter).isGreaterThan(lastExpiredDate); + assertTrue(timeDifferenceInSeconds( + (System.currentTimeMillis() + FileUtils.DEFAULT_DURATION_EXTENDED) / 1000, + dateExpiresAfter) <= 10); } } + private long timeDifferenceInSeconds(long timeAfter, long timeBefore) { + return timeAfter - timeBefore; + } + private Uri createExpiredTrashedItem(ContentResolver resolver, long dateExpires) throws Exception { return createExpiredTrashedItem(resolver, dateExpires, @@ -319,7 +490,7 @@ public class IdleServiceTest { private static String executeShellCommand(String command) throws IOException { Log.v(TAG, "$ " + command); ParcelFileDescriptor pfd = InstrumentationRegistry.getInstrumentation().getUiAutomation() - .executeShellCommand(command.toString()); + .executeShellCommand(command); BufferedReader br = null; try (InputStream in = new FileInputStream(pfd.getFileDescriptor());) { br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); diff --git a/tests/src/com/android/providers/media/IsolatedContext.java b/tests/src/com/android/providers/media/IsolatedContext.java index 98b1d2b48..ffcae9803 100644 --- a/tests/src/com/android/providers/media/IsolatedContext.java +++ b/tests/src/com/android/providers/media/IsolatedContext.java @@ -29,7 +29,12 @@ import android.provider.Settings; import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + import com.android.providers.media.cloudproviders.CloudProviderPrimary; +import com.android.providers.media.cloudproviders.FlakyCloudProvider; +import com.android.providers.media.dao.FileRow; import com.android.providers.media.photopicker.PhotoPickerProvider; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.util.FileUtils; @@ -45,6 +50,7 @@ public class IsolatedContext extends ContextWrapper { private final MockContentResolver mResolver; private final MediaProvider mMediaProvider; private final UserHandle mUserHandle; + private final FlakyCloudProvider mFlakyCloudProvider; public IsolatedContext(Context base, String tag, boolean asFuseThread) { this(base, tag, asFuseThread, base.getUser()); @@ -85,6 +91,9 @@ public class IsolatedContext extends ContextWrapper { final CloudMediaProvider cmp = new CloudProviderPrimary(); attachInfoAndAddProvider(base, cmp, CloudProviderPrimary.AUTHORITY); + mFlakyCloudProvider = new FlakyCloudProvider(); + attachInfoAndAddProvider(base, mFlakyCloudProvider, FlakyCloudProvider.AUTHORITY); + MediaStore.waitForIdle(mResolver); } @@ -110,6 +119,11 @@ public class IsolatedContext extends ContextWrapper { protected void storageNativeBootPropertyChangeListener() { // Ignore this as test app cannot read device config } + + @Override + protected void updateQuotaTypeForUri(@NonNull FileRow row) { + return; + } }; } @@ -153,4 +167,13 @@ public class IsolatedContext extends ContextWrapper { } } + @VisibleForTesting + public void setFlakyCloudProviderToFlakeInTheNextRequest() { + mFlakyCloudProvider.setToFlakeInTheNextRequest(); + } + + @VisibleForTesting + public void resetFlakyCloudProviderToNotFlakeInTheNextRequest() { + mFlakyCloudProvider.resetToNotFlakeInTheNextRequest(); + } } diff --git a/tests/src/com/android/providers/media/MediaGrantsTest.java b/tests/src/com/android/providers/media/MediaGrantsTest.java index aae4f8454..622c79048 100644 --- a/tests/src/com/android/providers/media/MediaGrantsTest.java +++ b/tests/src/com/android/providers/media/MediaGrantsTest.java @@ -16,8 +16,11 @@ package com.android.providers.media; +import static android.provider.MediaStore.MediaColumns.DATA; + import static com.android.providers.media.util.FileCreationUtils.buildValidPickerUri; import static com.android.providers.media.util.FileCreationUtils.insertFileInResolver; +import static com.android.providers.media.util.FileUtils.getContentUriForPath; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; @@ -25,6 +28,8 @@ import static org.junit.Assert.assertTrue; import android.Manifest; import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -40,6 +45,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.ArrayList; import java.util.List; @RunWith(AndroidJUnit4.class) @@ -51,8 +57,11 @@ public class MediaGrantsTest { private MediaGrants mGrants; private static final String TEST_OWNER_PACKAGE_NAME = "com.android.test.package"; + private static final String TEST_OWNER_PACKAGE_NAME2 = "com.android.test.package2"; private static final int TEST_USER_ID = UserHandle.myUserId(); + private static final String PNG_MIME_TYPE = "image/png"; + @BeforeClass public static void setUpClass() { androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() @@ -96,6 +105,225 @@ public class MediaGrantsTest { } @Test + public void testGetMediaGrantsForPackages() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + Long fileId3 = insertFileInResolver(mIsolatedResolver, "test_file3"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + List<Uri> uris2 = List.of(buildValidPickerUri(fileId3)); + + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME2, uris2, TEST_USER_ID); + + String[] mimeTypes = {PNG_MIME_TYPE}; + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + + List<Long> expectedFileIdsList = List.of(fileId1, fileId2); + + assertEquals(fileUris.size(), expectedFileIdsList.size()); + for (Uri uri : fileUris) { + assertTrue(expectedFileIdsList.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + + List<Uri> fileUrisForTestPackage2 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME2}, TEST_USER_ID, mimeTypes, volumes)); + + List<Long> expectedFileIdsList2 = List.of(fileId3); + + assertEquals(fileUrisForTestPackage2.size(), expectedFileIdsList2.size()); + for (Uri uri : fileUrisForTestPackage2) { + assertTrue(expectedFileIdsList2.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + + List<Uri> fileUrisForTestPackage3 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{"non.existent.package"}, TEST_USER_ID, mimeTypes, volumes)); + + // assert no items are returned for an invalid package. + assertEquals(/* expected= */fileUrisForTestPackage3.size(), /* actual= */0); + } + + @Test + public void test_GetMediaGrantsForPackages_excludesIsTrashed() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + + String[] mimeTypes = {PNG_MIME_TYPE}; + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + // Mark one of the files as trashed. + updateFileValues(fileId1, MediaStore.Files.FileColumns.IS_TRASHED, "1"); + + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + + // Now the 1st file with fileId1 should not be part of the returned grants. + List<Long> expectedFileIdsList = List.of(fileId2); + + assertEquals(fileUris.size(), expectedFileIdsList.size()); + for (Uri uri : fileUris) { + assertTrue(expectedFileIdsList.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + } + + @Test + public void test_GetMediaGrantsForPackages_excludesIsPending() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + + String[] mimeTypes = {PNG_MIME_TYPE}; + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + // Mark one of the files as pending. + updateFileValues(fileId1, MediaStore.Files.FileColumns.IS_PENDING, "1"); + + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + + // Now the 1st file with fileId1 should not be part of the returned grants. + List<Long> expectedFileIdsList = List.of(fileId2); + + assertEquals(fileUris.size(), expectedFileIdsList.size()); + for (Uri uri : fileUris) { + assertTrue(expectedFileIdsList.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + } + + @Test + public void test_GetMediaGrantsForPackages_testMimeTypeFilter() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + + Long fileId3 = insertFileInResolver(mIsolatedResolver, "test_file3", "mp4"); + List<Uri> uris2 = List.of(buildValidPickerUri(fileId3)); + + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris2, TEST_USER_ID); + + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + + // Test image only, should return 2 items. + String[] mimeTypes = {PNG_MIME_TYPE}; + + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + + List<Long> expectedFileIdsList = List.of(fileId1, fileId2); + assertEquals(fileUris.size(), expectedFileIdsList.size()); + for (Uri uri : fileUris) { + assertTrue(expectedFileIdsList.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + + // Test video only, should return 1 item. + String[] mimeTypes2 = {"video/mp4"}; + + List<Uri> fileUris2 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes2, volumes)); + List<Long> expectedFileIdsList2 = List.of(fileId3); + assertEquals(fileUris2.size(), expectedFileIdsList2.size()); + for (Uri uri : fileUris2) { + assertTrue(expectedFileIdsList2.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + + + // Test jpeg mimeType, since no items with this mimeType is granted, empty list should be + // returned. + String[] mimeTypes3 = {"image/jpeg"}; + List<Uri> fileUris3 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes3, volumes)); + assertTrue(fileUris3.isEmpty()); + } + + @Test + public void test_GetMediaGrantsForPackages_volume() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + + String[] volumes = {"test_volume"}; + String[] mimeTypes = {PNG_MIME_TYPE}; + + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + + assertTrue(fileUris.isEmpty()); + } + + @Test + public void testRemoveMediaGrantsForPackages() throws Exception { + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + Long fileId3 = insertFileInResolver(mIsolatedResolver, "test_file3"); + List<Uri> uris1 = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + List<Uri> uris2 = List.of(buildValidPickerUri(fileId3)); + + // Add grants for 2 different packages. + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris1, TEST_USER_ID); + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME2, uris2, TEST_USER_ID); + + String[] mimeTypes = {PNG_MIME_TYPE}; + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + + // Verify the grants for the first package were inserted. + List<Uri> fileUris = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, + mimeTypes, volumes)); + List<Long> expectedFileIdsList = List.of(fileId1, fileId2); + assertEquals(fileUris.size(), expectedFileIdsList.size()); + for (Uri uri : fileUris) { + assertTrue(expectedFileIdsList.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + + // Remove one of the 2 grants for TEST_OWNER_PACKAGE_NAME and verify the other grants is + // still present. + mGrants.removeMediaGrantsForPackage(new String[]{TEST_OWNER_PACKAGE_NAME}, + List.of(buildValidPickerUri(fileId1)), TEST_USER_ID); + List<Uri> fileUris3 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + assertEquals(1, fileUris3.size()); + assertEquals(fileId2, Long.valueOf(ContentUris.parseId(fileUris3.get(0)))); + + + // Verify grants of other packages are unaffected. + List<Uri> fileUrisForTestPackage2 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME2}, TEST_USER_ID, mimeTypes, volumes)); + List<Long> expectedFileIdsList2 = List.of(fileId3); + assertEquals(fileUrisForTestPackage2.size(), expectedFileIdsList2.size()); + for (Uri uri : fileUrisForTestPackage2) { + assertTrue(expectedFileIdsList2.contains(Long.valueOf(ContentUris.parseId(uri)))); + } + } + + @Test + public void testRemoveMediaGrantsForPackagesLargerDataSet() throws Exception { + List<Uri> inputFiles = new ArrayList<>(); + for (int itr = 1; itr < 110; itr++) { + inputFiles.add(buildValidPickerUri( + insertFileInResolver(mIsolatedResolver, "test_file" + itr))); + } + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, inputFiles, TEST_USER_ID); + + String[] mimeTypes = {PNG_MIME_TYPE}; + String[] volumes = {MediaStore.VOLUME_EXTERNAL_PRIMARY}; + + // The query used inside remove grants is batched by 50 ids, hence having a test like this + // would help ensure the batching worked perfectly. + mGrants.removeMediaGrantsForPackage(new String[]{TEST_OWNER_PACKAGE_NAME}, + inputFiles.subList(0, 101), TEST_USER_ID); + List<Uri> fileUris3 = convertToListOfUri(mGrants.getMediaGrantsForPackages( + new String[]{TEST_OWNER_PACKAGE_NAME}, TEST_USER_ID, mimeTypes, volumes)); + assertEquals(8, fileUris3.size()); + } + @Test public void testAddDuplicateMediaGrants() throws Exception { Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); @@ -139,8 +367,9 @@ public class MediaGrantsTest { assertGrantExistsForPackage(fileId1, TEST_OWNER_PACKAGE_NAME, TEST_USER_ID); assertGrantExistsForPackage(fileId2, TEST_OWNER_PACKAGE_NAME, TEST_USER_ID); - int removed = mGrants.removeAllMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, "test", - TEST_USER_ID); + int removed = + mGrants.removeAllMediaGrantsForPackages( + new String[] {TEST_OWNER_PACKAGE_NAME}, "test", TEST_USER_ID); assertEquals(2, removed); try (Cursor c = @@ -165,11 +394,73 @@ public class MediaGrantsTest { } @Test + public void removeAllMediaGrantsForMultiplePackages() throws Exception { + + Long fileId1 = insertFileInResolver(mIsolatedResolver, "test_file1"); + Long fileId2 = insertFileInResolver(mIsolatedResolver, "test_file2"); + List<Uri> uris = List.of(buildValidPickerUri(fileId1), buildValidPickerUri(fileId2)); + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME, uris, TEST_USER_ID); + mGrants.addMediaGrantsForPackage(TEST_OWNER_PACKAGE_NAME2, uris, TEST_USER_ID); + + assertGrantExistsForPackage(fileId1, TEST_OWNER_PACKAGE_NAME, TEST_USER_ID); + assertGrantExistsForPackage(fileId2, TEST_OWNER_PACKAGE_NAME, TEST_USER_ID); + assertGrantExistsForPackage(fileId1, TEST_OWNER_PACKAGE_NAME2, TEST_USER_ID); + assertGrantExistsForPackage(fileId2, TEST_OWNER_PACKAGE_NAME2, TEST_USER_ID); + + int removed = + mGrants.removeAllMediaGrantsForPackages( + new String[] {TEST_OWNER_PACKAGE_NAME, TEST_OWNER_PACKAGE_NAME2}, + "test", + TEST_USER_ID); + assertEquals(4, removed); + + try (Cursor c = + mExternalDatabase.runWithTransaction( + (db) -> + db.query( + MediaGrants.MEDIA_GRANTS_TABLE, + new String[] { + MediaGrants.FILE_ID_COLUMN, + MediaGrants.OWNER_PACKAGE_NAME_COLUMN + }, + String.format( + "%s = '%s'", + MediaGrants.OWNER_PACKAGE_NAME_COLUMN, + TEST_OWNER_PACKAGE_NAME), + null, + null, + null, + null))) { + assertEquals(0, c.getCount()); + } + + try (Cursor c = + mExternalDatabase.runWithTransaction( + (db) -> + db.query( + MediaGrants.MEDIA_GRANTS_TABLE, + new String[] { + MediaGrants.FILE_ID_COLUMN, + MediaGrants.OWNER_PACKAGE_NAME_COLUMN + }, + String.format( + "%s = '%s'", + MediaGrants.OWNER_PACKAGE_NAME_COLUMN, + TEST_OWNER_PACKAGE_NAME2), + null, + null, + null, + null))) { + assertEquals(0, c.getCount()); + } + } + + @Test public void removeAllMediaGrantsForPackageRequiresNonEmpty() throws Exception { assertThrows( IllegalArgumentException.class, () -> { - mGrants.removeAllMediaGrantsForPackage("", "test", TEST_USER_ID); + mGrants.removeAllMediaGrantsForPackages(new String[]{}, "test", TEST_USER_ID); }); } @@ -273,4 +564,34 @@ public class MediaGrantsTest { assertEquals(packageName, ownerValue); } } + + private List<Uri> convertToListOfUri(Cursor c) { + List<Uri> filesUriList = new ArrayList<>(0); + while (c.moveToNext()) { + final String file_path = c.getString(c.getColumnIndexOrThrow(DATA)); + final Integer file_id = c.getInt(c.getColumnIndexOrThrow(MediaGrants.FILE_ID_COLUMN)); + filesUriList.add(getContentUriForPath( + file_path).buildUpon().appendPath(String.valueOf(file_id)).build()); + } + return filesUriList; + } + + /** + * Modify column value for the fileId passed in the parameters with the modifiedValue. + */ + private void updateFileValues(Long fileId, String columnToBeModified, String modifiedValue) { + int numberOfUpdatedRows = mExternalDatabase.runWithTransaction( + (db) -> { + ContentValues updatedRowValue = new ContentValues(); + updatedRowValue.put(columnToBeModified, modifiedValue); + return db.update(MediaStore.Files.TABLE, + updatedRowValue, + String.format( + "%s = '%s'", + MediaStore.Files.FileColumns._ID, + Long.toString(fileId)), + null); + }); + assertEquals(/* expected */ 1, numberOfUpdatedRows); + } } diff --git a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java index 6ab4950f6..5f38a6217 100644 --- a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java +++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java @@ -21,6 +21,8 @@ import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_DEL import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_READ; import static com.android.providers.media.MediaProvider.DIRECTORY_ACCESS_FOR_WRITE; +import static org.junit.Assert.fail; + import android.Manifest; import android.app.UiAutomation; import android.content.ContentResolver; @@ -182,6 +184,23 @@ public class MediaProviderForFuseTest { } } + @Test + public void test_syntheticPathLookUpWithInvalidUid_throwsSecurityException() throws Exception { + try { + // Attempt a lookup for path that is synthetic and is a picker uri. Since the test + // uid is not the owner of the directory, the lookup should fail in the first step of + // the process that is, mContext.checkUriPermission and should throw a security + // exception. + sMediaProvider.onFileLookupForFuse( + "/storage/emulated/0/.transforms/synthetic/picker/0/com.android.providers" + + ".media.photopicker/media/1000000.jpg", sTestUid /* uid */, + 0 /* tid */); + fail("This test should throw a security exception"); + } catch (SecurityException se) { + // no-op. + } + } + private @NonNull File createNomediaFile(@NonNull File dir) throws IOException { final File nomediaFile = new File(dir, ".nomedia"); executeShellCommand("touch " + nomediaFile.getAbsolutePath()); diff --git a/tests/src/com/android/providers/media/MediaProviderTest.java b/tests/src/com/android/providers/media/MediaProviderTest.java index 823e9018f..fe13649f1 100644 --- a/tests/src/com/android/providers/media/MediaProviderTest.java +++ b/tests/src/com/android/providers/media/MediaProviderTest.java @@ -73,6 +73,7 @@ import com.android.providers.media.MediaProvider.FallbackException; import com.android.providers.media.MediaProvider.VolumeArgumentException; import com.android.providers.media.MediaProvider.VolumeNotFoundException; import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.ItemsProvider; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.FileUtilsTest; import com.android.providers.media.util.SQLiteQueryBuilder; @@ -106,6 +107,8 @@ public class MediaProviderTest { static final String PERMISSIONLESS_APP = "com.android.providers.media.testapp.withoutperms"; private static Context sIsolatedContext; + + private static ItemsProvider sItemsProvider; private static Context sContext; private static ContentResolver sIsolatedResolver; @@ -115,6 +118,11 @@ public class MediaProviderTest { .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, Manifest.permission.READ_COMPAT_CHANGE_CONFIG, Manifest.permission.READ_DEVICE_CONFIG, + // Adding this to use getUserHandles() api of UserManagerService which + // requires either MANAGE_USERS or CREATE_USERS. Since shell does not have + // MANAGER_USERS permissions, using CREATE_USERS in test. This works with + // MANAGE_USERS permission for MediaProvider module. + Manifest.permission.CREATE_USERS, Manifest.permission.INTERACT_ACROSS_USERS); resetIsolatedContext(); @@ -338,6 +346,90 @@ public class MediaProviderTest { } + @Test + public void testGetReadGrantsForPackage() throws Exception { + final File dir = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + final File testFile = stage(R.raw.lg_g4_iso_800_jpg, + new File(dir, "test" + System.nanoTime() + ".jpg")); + final Uri uri = MediaStore.scanFile(sIsolatedResolver, testFile); + Long fileId = ContentUris.parseId(uri); + + final Uri.Builder builder = Uri.EMPTY.buildUpon(); + builder.scheme("content"); + builder.encodedAuthority(MediaStore.AUTHORITY); + + final Uri testUri = builder.appendPath("picker") + .appendPath(Integer.toString(UserHandle.myUserId())) + .appendPath(PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY) + .appendPath(MediaStore.AUTHORITY) + .appendPath(Long.toString(fileId)) + .build(); + + try { + String[] mimeTypes = {"image/*"}; + // Verify empty list with no grants. + List<Uri> grantedUris = sItemsProvider.fetchReadGrantedItemsUrisForPackage( + android.os.Process.myUid(), mimeTypes); + assertTrue(grantedUris.isEmpty()); + + // Grants the READ-GRANT for the testUris for the current package. + MediaStore.grantMediaReadForPackage(sIsolatedContext, + android.os.Process.myUid(), + List.of(testUri)); + + // Assert that the grant was returned. + List<Uri> grantedUris2 = sItemsProvider.fetchReadGrantedItemsUrisForPackage( + android.os.Process.myUid(), mimeTypes); + assertEquals(ContentUris.parseId(uri), ContentUris.parseId(grantedUris2.get(0))); + } finally { + dir.delete(); + testFile.delete(); + } + } + + @Test + public void testRevokeReadGrantsForPackage() throws Exception { + final File dir = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + final File testFile = stage(R.raw.lg_g4_iso_800_jpg, + new File(dir, "test" + System.nanoTime() + ".jpg")); + final Uri uri = MediaStore.scanFile(sIsolatedResolver, testFile); + Long fileId = ContentUris.parseId(uri); + + final Uri.Builder builder = Uri.EMPTY.buildUpon(); + builder.scheme("content"); + builder.encodedAuthority(MediaStore.AUTHORITY); + + final Uri testUri = builder.appendPath("picker") + .appendPath(Integer.toString(UserHandle.myUserId())) + .appendPath(PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY) + .appendPath(MediaStore.AUTHORITY) + .appendPath(Long.toString(fileId)) + .build(); + + try { + String[] mimeTypes = {"image/*"}; + MediaStore.grantMediaReadForPackage(sIsolatedContext, + android.os.Process.myUid(), + List.of(testUri)); + List<Uri> grantedUris = sItemsProvider.fetchReadGrantedItemsUrisForPackage( + android.os.Process.myUid(), mimeTypes); + assertEquals(ContentUris.parseId(uri), ContentUris.parseId(grantedUris.get(0))); + + // Revoked the grant that was provided to testUri and verify that now the current + // package has no grants. + MediaStore.revokeMediaReadForPackages(sIsolatedContext, android.os.Process.myUid(), + grantedUris); + List<Uri> grantedUris2 = sItemsProvider.fetchReadGrantedItemsUrisForPackage( + android.os.Process.myUid(), mimeTypes); + assertEquals(0, grantedUris2.size()); + } finally { + dir.delete(); + testFile.delete(); + } + } + /** * We already have solid coverage of this logic in * {@code CtsProviderTestCases}, but the coverage system currently doesn't @@ -1746,5 +1838,6 @@ public class MediaProviderTest { sContext = InstrumentationRegistry.getTargetContext(); sIsolatedContext = new IsolatedContext(sContext, "modern", /*asFuseThread*/ false); sIsolatedResolver = sIsolatedContext.getContentResolver(); + sItemsProvider = new ItemsProvider(sIsolatedContext); } } diff --git a/tests/src/com/android/providers/media/PickerProviderMediaGenerator.java b/tests/src/com/android/providers/media/PickerProviderMediaGenerator.java index b8c2ca63e..696300f65 100644 --- a/tests/src/com/android/providers/media/PickerProviderMediaGenerator.java +++ b/tests/src/com/android/providers/media/PickerProviderMediaGenerator.java @@ -18,6 +18,7 @@ package com.android.providers.media; import static android.provider.CloudMediaProviderContract.AlbumColumns; import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE; import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo; import static android.provider.CloudMediaProviderContract.MediaColumns; @@ -94,17 +95,38 @@ public class PickerProviderMediaGenerator { private Intent mAccountConfigurationIntent; private int mCursorExtraQueryCount; private Bundle mCursorExtra; - - // TODO(b/214592293): Add pagination support for testing purposes. - public Cursor getMedia(long generation, String albumId, String[] mimeTypes, - long sizeBytes) { - final Cursor cursor = getCursor(mMedia, generation, albumId, mimeTypes, sizeBytes, - /* isDeleted */ false); + private Integer mNextPageToken; + + public Cursor getMedia( + long generation, String albumId, String[] mimeTypes, long sizeBytes, int pageSize) { + return getMedia(generation, albumId, mimeTypes, sizeBytes, null, pageSize); + } + + public Cursor getMedia( + long generation, + String albumId, + String[] mimeTypes, + long sizeBytes, + String pageToken, + int pageSize) { + final Cursor cursor = + getCursor( + mMedia, + generation, + albumId, + mimeTypes, + sizeBytes, + /* isDeleted */ false, + pageToken); if (mCursorExtra != null) { cursor.setExtras(mCursorExtra); } else { - cursor.setExtras(buildCursorExtras(mCollectionId, generation > 0, albumId != null)); + cursor.setExtras( + buildCursorExtras( + mCollectionId, generation > 0, albumId != null, mNextPageToken, + pageSize > -1)); + mNextPageToken = null; } if (--mCursorExtraQueryCount == 0) { @@ -114,12 +136,19 @@ public class PickerProviderMediaGenerator { } public Cursor getAlbums(String[] mimeTypes, long sizeBytes, boolean isLocal) { - final Cursor cursor = getCursor(mAlbums, mimeTypes, sizeBytes, isLocal); + return getAlbums(mimeTypes, sizeBytes, isLocal, /* pageToken= */ null); + } + + public Cursor getAlbums( + String[] mimeTypes, long sizeBytes, boolean isLocal, String pageToken) { + final Cursor cursor = getCursor(mAlbums, mimeTypes, sizeBytes, isLocal, pageToken); if (mCursorExtra != null) { cursor.setExtras(mCursorExtra); } else { - cursor.setExtras(buildCursorExtras(mCollectionId, false, false)); + cursor.setExtras(buildCursorExtras(mCollectionId, false, false, mNextPageToken, + false)); + mNextPageToken = null; } if (--mCursorExtraQueryCount == 0) { @@ -128,16 +157,21 @@ public class PickerProviderMediaGenerator { return cursor; } - // TODO(b/214592293): Add pagination support for testing purposes. public Cursor getDeletedMedia(long generation) { + return getDeletedMedia(generation, /* pageToken= */ null); + } + public Cursor getDeletedMedia(long generation, String pageToken) { final Cursor cursor = getCursor(mDeletedMedia, generation, /* albumId */ STRING_DEFAULT, STRING_ARRAY_DEFAULT, /* sizeBytes */ LONG_DEFAULT, - /* isDeleted */ true); + /* isDeleted */ true, pageToken); if (mCursorExtra != null) { cursor.setExtras(mCursorExtra); } else { - cursor.setExtras(buildCursorExtras(mCollectionId, generation > 0, false)); + cursor.setExtras( + buildCursorExtras(mCollectionId, generation > 0, false, mNextPageToken, + false)); + mNextPageToken = null; } if (--mCursorExtraQueryCount == 0) { @@ -167,14 +201,23 @@ public class PickerProviderMediaGenerator { } public void setNextCursorExtras(int queryCount, String mediaCollectionId, - boolean honoredSyncGeneration, boolean honoredAlbumId) { + boolean honoredSyncGeneration, boolean honoredAlbumId, boolean honouredPageSize) { mCursorExtraQueryCount = queryCount; - mCursorExtra = buildCursorExtras(mediaCollectionId, honoredSyncGeneration, - honoredAlbumId); - } - - public Bundle buildCursorExtras(String mediaCollectionId, boolean honoredSyncGeneration, - boolean honoredAlbumdId) { + mCursorExtra = + buildCursorExtras( + mediaCollectionId, + honoredSyncGeneration, + honoredAlbumId, + mNextPageToken, + honouredPageSize); + } + + public Bundle buildCursorExtras( + String mediaCollectionId, + boolean honoredSyncGeneration, + boolean honoredAlbumdId, + Integer pageToken, + boolean honouredPageSize) { final ArrayList<String> honoredArgs = new ArrayList<>(); if (honoredSyncGeneration) { honoredArgs.add(EXTRA_SYNC_GENERATION); @@ -183,10 +226,17 @@ public class PickerProviderMediaGenerator { honoredArgs.add(EXTRA_ALBUM_ID); } + if (honouredPageSize) { + honoredArgs.add(EXTRA_PAGE_SIZE); + } + final Bundle bundle = new Bundle(); - bundle.putString(CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID, - mediaCollectionId); + bundle.putString( + CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID, mediaCollectionId); bundle.putStringArrayList(ContentResolver.EXTRA_HONORED_ARGS, honoredArgs); + if (pageToken != null) { + bundle.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, pageToken.toString()); + } return bundle; } @@ -223,6 +273,7 @@ public class PickerProviderMediaGenerator { mDeletedMedia.clear(); mAlbums.clear(); clearCursorExtras(); + mNextPageToken = null; } public void setMediaCollectionId(String id) { @@ -241,6 +292,7 @@ public class PickerProviderMediaGenerator { // Increase generation return new TestMedia(localId, cloudId, ++mLastSyncGeneration); } + private TestMedia createTestAlbumMedia(String localId, String cloudId, String albumId) { // Increase generation return new TestMedia(localId, cloudId, albumId); @@ -260,37 +312,90 @@ public class PickerProviderMediaGenerator { return new TestMedia(localId, cloudId, 0); } - private static Cursor getCursor(List<TestMedia> mediaList, long generation, - String albumId, String[] mimeTypes, long sizeBytes, boolean isDeleted) { + private Cursor getCursor( + List<TestMedia> mediaList, + long generation, + String albumId, + String[] mimeTypes, + long sizeBytes, + boolean isDeleted, + String pageToken) { final MatrixCursor matrix; + final int pageSize = 5; + if (isDeleted) { matrix = new MatrixCursor(DELETED_MEDIA_PROJECTION); - } else if(!TextUtils.isEmpty(albumId)) { + } else if (!TextUtils.isEmpty(albumId)) { matrix = new MatrixCursor(ALBUM_MEDIA_PROJECTION); } else { matrix = new MatrixCursor(MEDIA_PROJECTION); } - for (TestMedia media : mediaList) { - if (!TextUtils.isEmpty(albumId) && matchesFilter(media, - albumId, mimeTypes, sizeBytes)) { - matrix.addRow(media.toAlbumMediaArray()); - } else if (media.generation > generation - && matchesFilter(media, albumId, mimeTypes, sizeBytes)) { - matrix.addRow(media.toArray(isDeleted)); + int page = 0; + if (pageToken != null) { + page = Integer.parseInt(pageToken); + } + + // Calculate the starting position: pageSize * pageNumber + int startPosition = (pageSize * page); + // Calculate the end of the page + int endPosition = startPosition + pageSize; + + for (int i = startPosition; i < endPosition; i++) { + + try { + TestMedia media = mediaList.get(i); + if (!TextUtils.isEmpty(albumId) + && matchesFilter(media, albumId, mimeTypes, sizeBytes)) { + matrix.addRow(media.toAlbumMediaArray()); + } else if (media.generation > generation + && matchesFilter(media, albumId, mimeTypes, sizeBytes)) { + matrix.addRow(media.toArray(isDeleted)); + } + + } catch (IndexOutOfBoundsException e) { + // We're at the end of the list, before the end of the page so break the loop. + break; } } + + // Set next page token if there is another page. + if (mediaList.size() > endPosition) { + mNextPageToken = Integer.valueOf(++page); + } else { + mNextPageToken = null; + } + return matrix; } private static Cursor getCursor(List<TestAlbum> albumList, String[] mimeTypes, - long sizeBytes, boolean isLocal) { + long sizeBytes, boolean isLocal, String pageToken) { final MatrixCursor matrix = new MatrixCursor(ALBUM_PROJECTION); + final int pageSize = 5; - for (TestAlbum album : albumList) { - final String[] res = album.toArray(mimeTypes, sizeBytes, isLocal); - if (res != null) { - matrix.addRow(res); + int page = 0; + if (pageToken != null) { + page = Integer.parseInt(pageToken); + } + + // Calculate the starting position: pageSize * pageNumber + int startPosition = (pageSize * page); + // Calculate the end of the page + int endPosition = startPosition + pageSize; + + + for (int i = startPosition; i < endPosition; i++) { + + try { + TestAlbum album = albumList.get(i); + final String[] res = album.toArray(mimeTypes, sizeBytes, isLocal); + if (res != null) { + matrix.addRow(res); + } + } catch (IndexOutOfBoundsException e) { + // We're at the end of the list, before the end of the page so break the loop. + break; } } return matrix; diff --git a/tests/src/com/android/providers/media/PickerUriResolverTest.java b/tests/src/com/android/providers/media/PickerUriResolverTest.java index d84e42936..3d8c2604b 100644 --- a/tests/src/com/android/providers/media/PickerUriResolverTest.java +++ b/tests/src/com/android/providers/media/PickerUriResolverTest.java @@ -53,6 +53,7 @@ import androidx.test.runner.AndroidJUnit4; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -79,7 +80,7 @@ public class PickerUriResolverTest { private static class TestPickerUriResolver extends PickerUriResolver { TestPickerUriResolver(Context context) { - super(context, new PickerDbFacade(getTargetContext()), + super(context, new PickerDbFacade(getTargetContext(), new PickerSyncLockManager()), new ProjectionHelper(Column.class, ExportedSince.class)); } diff --git a/tests/src/com/android/providers/media/PublicVolumeTest.java b/tests/src/com/android/providers/media/PublicVolumeTest.java index e2a272f2b..aaed1f9de 100644 --- a/tests/src/com/android/providers/media/PublicVolumeTest.java +++ b/tests/src/com/android/providers/media/PublicVolumeTest.java @@ -30,6 +30,7 @@ import android.provider.MediaStore; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.tests.utils.PublicVolumeSetupHelper; import com.android.providers.media.util.FileUtils; @@ -43,6 +44,7 @@ import java.io.File; import java.util.List; @RunWith(AndroidJUnit4.class) +@RunOnlyOnPostsubmit public class PublicVolumeTest { static final int POLL_DELAY_MS = 500; static final int WAIT_FOR_DEFAULT_FOLDERS_MS = 30000; diff --git a/tests/src/com/android/providers/media/TestConfigStore.java b/tests/src/com/android/providers/media/TestConfigStore.java index dddddb260..ca38ecc29 100644 --- a/tests/src/com/android/providers/media/TestConfigStore.java +++ b/tests/src/com/android/providers/media/TestConfigStore.java @@ -18,9 +18,12 @@ package com.android.providers.media; import static java.util.Objects.requireNonNull; +import android.util.Pair; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -32,37 +35,58 @@ import java.util.concurrent.Executor; */ public class TestConfigStore implements ConfigStore { private boolean mCloudMediaInPhotoPickerEnabled = false; - private @Nullable List<String> mAllowedCloudProviderPackages = null; + + private boolean mPickerChoiceManagedSelectionEnabled = false; + private List<String> mAllowedCloudProviderPackages = Collections.emptyList(); private @Nullable String mDefaultCloudProviderPackage = null; - private int mPickerSyncDelayMs = 0; + private List<Pair<Executor, Runnable>> mObservers = new ArrayList<>(); public void enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(String... providers) { mAllowedCloudProviderPackages = Arrays.asList(providers); - enableCloudMediaFeature(); + mCloudMediaInPhotoPickerEnabled = true; + notifyObservers(); } public void enableCloudMediaFeature() { mCloudMediaInPhotoPickerEnabled = true; + notifyObservers(); } public void clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature() { - mAllowedCloudProviderPackages = null; + mAllowedCloudProviderPackages = Collections.emptyList(); disableCloudMediaFeature(); + notifyObservers(); } public void disableCloudMediaFeature() { mCloudMediaInPhotoPickerEnabled = false; + notifyObservers(); + } + + /** + * Enables pickerChoiceManagedSelection flag in the test config. + */ + public void enablePickerChoiceManagedSelectionEnabled() { + mPickerChoiceManagedSelectionEnabled = true; } @Override public @NonNull List<String> getAllowedCloudProviderPackages() { - return mAllowedCloudProviderPackages != null ? mAllowedCloudProviderPackages - : Collections.emptyList(); + return mAllowedCloudProviderPackages; + } + + public void setAllowedCloudProviderPackages(String... providers) { + if (providers.length == 0) { + mAllowedCloudProviderPackages = Collections.emptyList(); + } else { + mAllowedCloudProviderPackages = Arrays.asList(providers); + } + notifyObservers(); } @Override public boolean isCloudMediaInPhotoPickerEnabled() { - return mCloudMediaInPhotoPickerEnabled; + return mCloudMediaInPhotoPickerEnabled && !mAllowedCloudProviderPackages.isEmpty(); } public void setDefaultCloudProviderPackage(@NonNull String packageName) { @@ -81,15 +105,6 @@ public class TestConfigStore implements ConfigStore { return mDefaultCloudProviderPackage; } - @Override - public int getPickerSyncDelayMs() { - return mPickerSyncDelayMs; - } - - public void setPickerSyncDelayMs(int delay) { - mPickerSyncDelayMs = delay; - } - @NonNull @Override public List<String> getTranscodeCompatManifest() { @@ -103,7 +118,24 @@ public class TestConfigStore implements ConfigStore { } @Override + public boolean isPickerChoiceManagedSelectionEnabled() { + return mPickerChoiceManagedSelectionEnabled; + } + + @Override public void addOnChangeListener(@NonNull Executor executor, @NonNull Runnable listener) { - // No-op. + Pair p = Pair.create(executor, listener); + mObservers.add(p); + } + + + /** + * Runs all subscribers to the TestConfigStore. + */ + private void notifyObservers() { + for (Pair<Executor, Runnable> observer: mObservers) { + // Run tasks in a synchronous manner to avoid test flakes. + observer.second.run(); + } } } diff --git a/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java b/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java index 3bb7a07b5..fc2dc77a4 100644 --- a/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java +++ b/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java @@ -25,6 +25,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -74,7 +75,7 @@ public class TestDatabaseBackupAndRecovery extends DatabaseBackupAndRecovery { } @Override - protected FuseDaemon getFuseDaemonForFileWithWait(File fuseFilePath, long waitTime) + protected FuseDaemon getFuseDaemonForFileWithWait(File fuseFilePath) throws FileNotFoundException { return null; } @@ -87,4 +88,12 @@ public class TestDatabaseBackupAndRecovery extends DatabaseBackupAndRecovery { public void setBackedUpData(Map<String, BackupIdRow> backedUpData) { this.mBackedUpData = backedUpData; } + + @Override + protected void removeRecoveryDataForUserId(int removedUserId) { + } + + @Override + public void removeRecoveryDataExceptValidUsers(List<String> validUsers) { + } } diff --git a/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java b/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java index 39dc32f39..8e62baa75 100644 --- a/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java +++ b/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java @@ -16,6 +16,8 @@ package com.android.providers.media.cloudproviders; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; + import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; import android.content.res.AssetFileDescriptor; @@ -52,8 +54,11 @@ public class CloudProviderPrimary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + return mMediaGenerator.getMedia(queryExtras.getGeneration(), queryExtras.getAlbumId(), - queryExtras.getMimeTypes(), queryExtras.getSizeBytes()); + queryExtras.getMimeTypes(), queryExtras.getSizeBytes(), pageToken, + queryExtras.getPageSize()); } @Override @@ -61,7 +66,8 @@ public class CloudProviderPrimary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); - return mMediaGenerator.getDeletedMedia(queryExtras.getGeneration()); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + return mMediaGenerator.getDeletedMedia(queryExtras.getGeneration(), pageToken); } @Override @@ -69,8 +75,9 @@ public class CloudProviderPrimary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); return mMediaGenerator.getAlbums(queryExtras.getMimeTypes(), queryExtras.getSizeBytes(), - /* isLocal */ false); + /* isLocal */ false, pageToken); } @Override diff --git a/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java b/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java index a00cbafc3..5c3df94d8 100644 --- a/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java +++ b/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java @@ -16,6 +16,8 @@ package com.android.providers.media.cloudproviders; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; + import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; import android.content.res.AssetFileDescriptor; @@ -36,7 +38,7 @@ import java.io.FileNotFoundException; * {@link MediaGenerator} */ public class CloudProviderSecondary extends CloudMediaProvider { - private static final String AUTHORITY = + public static final String AUTHORITY = "com.android.providers.media.photopicker.tests.cloud_secondary"; private final MediaGenerator mMediaGenerator = @@ -52,8 +54,11 @@ public class CloudProviderSecondary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + return mMediaGenerator.getMedia(queryExtras.getGeneration(), queryExtras.getAlbumId(), - queryExtras.getMimeTypes(), queryExtras.getSizeBytes()); + queryExtras.getMimeTypes(), queryExtras.getSizeBytes(), pageToken, + queryExtras.getPageSize()); } @Override @@ -61,7 +66,8 @@ public class CloudProviderSecondary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); - return mMediaGenerator.getDeletedMedia(queryExtras.getGeneration()); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + return mMediaGenerator.getDeletedMedia(queryExtras.getGeneration(), pageToken); } @Override @@ -69,8 +75,9 @@ public class CloudProviderSecondary extends CloudMediaProvider { final CloudProviderQueryExtras queryExtras = CloudProviderQueryExtras.fromCloudMediaBundle(extras); + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); return mMediaGenerator.getAlbums(queryExtras.getMimeTypes(), queryExtras.getSizeBytes(), - /* isLocal */ false); + /* isLocal */ false, pageToken); } @Override diff --git a/tests/src/com/android/providers/media/cloudproviders/FlakyCloudProvider.java b/tests/src/com/android/providers/media/cloudproviders/FlakyCloudProvider.java new file mode 100644 index 000000000..2d20574b8 --- /dev/null +++ b/tests/src/com/android/providers/media/cloudproviders/FlakyCloudProvider.java @@ -0,0 +1,170 @@ +/* + * 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.cloudproviders; + +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; + +import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.graphics.Point; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.CloudMediaProvider; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; + +import com.android.providers.media.PickerProviderMediaGenerator; +import com.android.providers.media.photopicker.data.CloudProviderQueryExtras; + +import java.io.FileNotFoundException; + +/** + * Implements a cloud {@link CloudMediaProvider} interface over items generated with {@link + * MediaGenerator} + * + * <p>This provider is intentionally very flaky and will throw a {@link RuntimeException} for two + * out of every three requests. + */ +public class FlakyCloudProvider extends CloudMediaProvider { + private static final String TAG = "FlakyCloudProvider"; + public static final String AUTHORITY = + "com.android.providers.media.photopicker.tests.cloud_flaky"; + public static final String ACCOUNT_NAME = "test_account@flakyCloudProvider"; + private static final int INITIAL_REQUEST_COUNT = 0; + private static final int REQUEST_COUNT_FOR_NEXT_ONE_TO_FLAKE = 2; + + private final MediaGenerator mMediaGenerator = + PickerProviderMediaGenerator.getMediaGenerator(AUTHORITY); + + private int mRequestCount = INITIAL_REQUEST_COUNT; + + /** Determines if the current request should flake. */ + private boolean shouldFlake() { + + // Always succeed on the first request. + if (++mRequestCount < REQUEST_COUNT_FOR_NEXT_ONE_TO_FLAKE) { + return false; + } + + if (mRequestCount > REQUEST_COUNT_FOR_NEXT_ONE_TO_FLAKE) { + mRequestCount = INITIAL_REQUEST_COUNT; + } + + return true; + } + + @Override + public boolean onCreate() { + mMediaGenerator.setAccountInfo(ACCOUNT_NAME, /* configIntent= */ null); + return true; + } + + @Override + public Cursor onQueryMedia(Bundle extras) { + final CloudProviderQueryExtras queryExtras = + CloudProviderQueryExtras.fromCloudMediaBundle(extras); + + if (shouldFlake()) { + throw new RuntimeException("Simulating a crash in FlakyCloudProvider onQueryMedia"); + } + + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + + return mMediaGenerator.getMedia( + queryExtras.getGeneration(), + queryExtras.getAlbumId(), + queryExtras.getMimeTypes(), + queryExtras.getSizeBytes(), + pageToken, + queryExtras.getPageSize()); + } + + @Override + public Cursor onQueryDeletedMedia(Bundle extras) { + final CloudProviderQueryExtras queryExtras = + CloudProviderQueryExtras.fromCloudMediaBundle(extras); + + if (shouldFlake()) { + throw new RuntimeException( + "Simulating a crash in FlakyCloudProvider onQueryDeletedMedia"); + } + + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + + return mMediaGenerator.getDeletedMedia(queryExtras.getGeneration(), pageToken); + } + + @Override + public Cursor onQueryAlbums(Bundle extras) { + final CloudProviderQueryExtras queryExtras = + CloudProviderQueryExtras.fromCloudMediaBundle(extras); + + if (shouldFlake()) { + throw new RuntimeException("Simulating a crash in FlakyCloudProvider onQueryAlbums"); + } + + String pageToken = extras.getString(EXTRA_PAGE_TOKEN, null); + + return mMediaGenerator.getAlbums( + queryExtras.getMimeTypes(), + queryExtras.getSizeBytes(), /* isLocal */ + false, + pageToken); + } + + @Override + public AssetFileDescriptor onOpenPreview( + String mediaId, Point size, Bundle extras, CancellationSignal signal) + throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenPreview not supported"); + } + + @Override + public ParcelFileDescriptor onOpenMedia( + String mediaId, Bundle extras, CancellationSignal signal) throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenMedia not supported"); + } + + @Override + public Bundle onGetMediaCollectionInfo(Bundle extras) { + if (shouldFlake()) { + try { + MILLISECONDS.sleep(/* timeout= */ 200L); + } catch (InterruptedException e) { + Log.d(TAG, "Error while sleep when should flake on get media collection info.", e); + } + } + + return mMediaGenerator.getMediaCollectionInfo(); + } + + @VisibleForTesting + public void resetToNotFlakeInTheNextRequest() { + mRequestCount = INITIAL_REQUEST_COUNT; + } + + @VisibleForTesting + public void setToFlakeInTheNextRequest() { + mRequestCount = REQUEST_COUNT_FOR_NEXT_ONE_TO_FLAKE; + } +} diff --git a/tests/src/com/android/providers/media/library/RunOnlyOnPostsubmit.java b/tests/src/com/android/providers/media/library/RunOnlyOnPostsubmit.java new file mode 100644 index 000000000..4bc607257 --- /dev/null +++ b/tests/src/com/android/providers/media/library/RunOnlyOnPostsubmit.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 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.library; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Tests marked with this annotation will only run on postsubmit and not on presubmit. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface RunOnlyOnPostsubmit { +} diff --git a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java index 6d8c725cc..41c561458 100644 --- a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java +++ b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java @@ -32,17 +32,22 @@ import static com.android.providers.media.util.MimeUtils.isVideoMimeType; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.assertTrue; + import android.Manifest; import android.app.Instrumentation; import android.app.UiAutomation; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.os.CancellationSignal; import android.os.Environment; +import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.provider.CloudMediaProviderContract; import android.provider.MediaStore; @@ -57,7 +62,9 @@ import com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; import com.android.providers.media.TestConfigStore; import com.android.providers.media.cloudproviders.CloudProviderPrimary; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.PaginationParameters; import com.android.providers.media.photopicker.data.model.Category; +import com.android.providers.media.photopicker.data.model.Item; import com.android.providers.media.photopicker.data.model.UserId; import com.google.common.io.ByteStreams; @@ -75,6 +82,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; public class ItemsProviderTest { /** @@ -102,13 +110,11 @@ public class ItemsProviderTest { public void setUp() throws Exception { final UiAutomation uiAutomation = sInstrumentation.getUiAutomation(); uiAutomation.adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, - Manifest.permission.READ_COMPAT_CHANGE_CONFIG, - Manifest.permission.READ_DEVICE_CONFIG, - Manifest.permission.INTERACT_ACROSS_USERS); + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.READ_DEVICE_CONFIG, + Manifest.permission.INTERACT_ACROSS_USERS); mConfigStore = new TestConfigStore(); - // Remove sync delay to avoid flaky tests - mConfigStore.setPickerSyncDelayMs(0); final Context isolatedContext = new IsolatedContext(sTargetContext, /* tag */ "databases", /* asFuseThread */ false, sTargetContext.getUser(), mConfigStore); @@ -125,12 +131,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_CAMERA}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} + * to return correct info about {@link AlbumColumns#ALBUM_ID_CAMERA}. */ @Test public void testGetCategories_camera() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 image file in Camera dir to test @@ -144,12 +151,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_CAMERA}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_CAMERA}. */ @Test public void testGetCategories_not_camera() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // negative test case: image file which should not be returned in Camera category @@ -163,12 +171,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_VIDEOS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_VIDEOS}. */ @Test public void testGetCategories_videos() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 video file in Movies dir to test @@ -182,12 +191,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_VIDEOS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_VIDEOS}. */ @Test public void testGetCategories_not_videos() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // negative test case: image file which should not be returned in Videos category @@ -201,12 +211,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_SCREENSHOTS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_SCREENSHOTS}. */ @Test public void testGetCategories_screenshots() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 image file in Screenshots dir to test @@ -241,12 +252,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_SCREENSHOTS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_SCREENSHOTS}. */ @Test public void testGetCategories_not_screenshots() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // negative test case: image file which should not be returned in Screenshots category @@ -260,12 +272,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link AlbumColumns#ALBUM_ID_FAVORITES}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_FAVORITES}. */ @Test public void testGetCategories_favorites() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // positive test case: image file which should be returned in favorites category @@ -280,12 +293,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link AlbumColumns#ALBUM_ID_FAVORITES}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_FAVORITES}. */ @Test public void testGetCategories_not_favorites() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // negative test case: image file which should not be returned in favorites category @@ -299,12 +313,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_DOWNLOADS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_DOWNLOADS}. */ @Test public void testGetCategories_downloads() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 image file in Downloads dir to test @@ -318,12 +333,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_DOWNLOADS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_DOWNLOADS}. */ @Test public void testGetCategories_not_downloads() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // negative test case: image file which should not be returned in Downloads category @@ -337,12 +353,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link #ALBUM_ID_VIDEOS}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_VIDEOS}. */ @Test public void testGetCategories_camera_and_videos() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 video file in Camera dir to test @@ -359,12 +376,13 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link AlbumColumns#ALBUM_ID_FAVORITES}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_FAVORITES}. */ @Test public void testGetCategories_screenshots_and_favorites() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 image file in Screenshots dir to test @@ -382,12 +400,14 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllCategories(String[], UserId)} to return correct info - * about {@link AlbumColumns#ALBUM_ID_DOWNLOADS} and {@link AlbumColumns#ALBUM_ID_FAVORITES}. + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_DOWNLOADS} and + * {@link AlbumColumns#ALBUM_ID_FAVORITES}. */ @Test public void testGetCategories_downloads_and_favorites() throws Exception { - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); // Create 1 image file in Screenshots dir to test @@ -405,8 +425,10 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} to return all - * images and videos. + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId, + * CancellationSignal)} + * to return all images and videos. */ @Test public void testGetItems() throws Exception { @@ -415,8 +437,9 @@ public class ItemsProviderTest { File imageFile = assertCreateNewImage(); File videoFile = assertCreateNewVideo(); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ null, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(2); @@ -431,52 +454,235 @@ public class ItemsProviderTest { } } + /** + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId, + * CancellationSignal)} + * (Category, int, String[], UserId)} to stop execution when cancellation signal + * is triggered before query execution. + */ + @Test(expected = OperationCanceledException.class) + public void testGetItems_canceledBeforeQuery_ThrowsImmediately() throws Exception { + // Create 1 image and 1 video file to test + // Both files should be returned. + CancellationSignal cancellationSignal = new CancellationSignal(); + cancellationSignal.cancel(); + + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, + /* cancellationSignal */ cancellationSignal); + } + + /** + * Tests + * {@link ItemsProvider#getLocalItems(Category, PaginationParameters, String[], UserId, + * CancellationSignal)} + * (Category, int, String[], UserId)} to stop execution when cancellation signal + * is triggered before query execution. + */ + @Test(expected = OperationCanceledException.class) + public void testGetLocalItems_canceledBeforeQuery_ThrowsImmediately() throws Exception { + CancellationSignal cancellationSignal = new CancellationSignal(); + cancellationSignal.cancel(); + + mItemsProvider.getLocalItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, + /* cancellationSignal */ cancellationSignal); + } + + /** + * Tests + * {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} + * (Category, int, String[], UserId)} to stop execution when cancellation signal + * is triggered before query execution. + */ + @Test(expected = OperationCanceledException.class) + public void testGetCategories_canceledBeforeQuery_ThrowsImmediately() throws Exception { + CancellationSignal cancellationSignal = new CancellationSignal(); + cancellationSignal.cancel(); + + mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, cancellationSignal); + } + + /** + * Tests + * {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} + * (Category, int, String[], UserId)} to stop execution when cancellation signal + * is triggered before query execution. + */ + @Test(expected = OperationCanceledException.class) + public void testGetLocalCategories_canceledBeforeQuery_ThrowsImmediately() throws Exception { + CancellationSignal cancellationSignal = new CancellationSignal(); + cancellationSignal.cancel(); + + mItemsProvider.getLocalCategories(/* mimeType */ null, /* userId */ null, + cancellationSignal); + } + + /** + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId, + * CancellationSignal)} + * (Category, int, String[], UserId)} to return all + * images and videos. + */ + @Test + public void testGetItems_withLimit() throws Exception { + // Create 10 new files. + List<File> imageFiles = assertCreateNewImagesWithDifferentDateModifiedTimes(10); + try { + // Set the limit and ensure that only that number of items are returned. + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(/* limit */ 5, /*dateBeforeMs*/ Long.MIN_VALUE, -1), + /* mimeType */ null, /* userId */ null, /* cancellationSignal */ null); + assertThat(res).isNotNull(); + + // Since the limit was set to 5 only 5 items should be returned. + assertThat(res.getCount()).isEqualTo(5); + assertThatOnlyImagesVideos(res); + // Reset the cursor back. Cursor#moveToPosition(-1) will reset the position to -1, + // but since there is no such valid cursor position, it returns false. + assertThat(res.moveToPosition(-1)).isFalse(); + } finally { + for (File file : imageFiles) { + file.delete(); + } + } + } + + /** + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId, + * CancellationSignal)} + * (Category, int, String[], UserId)} to return paginated items. + */ @Test - public void testGetItems_sortOrder() throws Exception { + public void testGetItems_withPagination_sameDateModified() throws Exception { + // Create 10 new files, all with same time stamp. + List<File> imageFiles = assertCreateNewImagesWithSameDateModifiedTimes( + /* number of images */ 10); try { - final long timeNow = System.nanoTime() / 1000; - final Uri imageFileDateNowPlus1Uri = prepareFileAndGetUri( - new File(getDownloadsDir(), "latest_" + IMAGE_FILE_NAME), timeNow + 1000); - final Uri imageFileDateNowUri - = prepareFileAndGetUri(new File(getDcimDir(), IMAGE_FILE_NAME), timeNow); - final Uri videoFileDateNowUri - = prepareFileAndGetUri(new File(getCameraDir(), VIDEO_FILE_NAME), timeNow); - - // This is the list of uris based on the expected sort order of items returned by - // ItemsProvider#getAllItems - List<Uri> uris = new ArrayList<>(); - // This is the latest image file - uris.add(imageFileDateNowPlus1Uri); - // Video file was scanned after image file, hence has higher _id than image file - uris.add(videoFileDateNowUri); - uris.add(imageFileDateNowUri); - - try (Cursor cursor = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ null, /* userId */ null)) { - assertThat(cursor).isNotNull(); - - final int expectedCount = uris.size(); - assertThat(cursor.getCount()).isEqualTo(expectedCount); - - int rowNum = 0; - assertThat(cursor.moveToFirst()).isTrue(); - final int idColumnIndex = cursor.getColumnIndexOrThrow(MediaColumns.ID); - while (rowNum < expectedCount) { - assertWithMessage("id at row:" + rowNum + " is expected to be" - + " same as id in " + uris.get(rowNum)) - .that(String.valueOf(cursor.getLong(idColumnIndex))) - .isEqualTo(uris.get(rowNum).getLastPathSegment()); - cursor.moveToNext(); - rowNum++; - } + // all files should be returned. + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, /* cancellationSignal */ null); + assertThat(res).isNotNull(); + assertThat(res.getCount()).isEqualTo(10); + // create a list from the cursor. + List<Item> itemList = new ArrayList<>(10); + while (res.moveToNext()) { + Item item = Item.fromCursor(res, UserId.CURRENT_USER); + itemList.add(item); + } + res.moveToPosition(0); + assertThatOnlyImagesVideos(res); + + // For this test, paginate the above list by returning second half of the items using + // the pagingParameters created by the middle item of the above list. + PaginationParameters paginationParameters = new PaginationParameters( + /* pageSize */ 5, + /* dateTaken for the last item of the previous page */ + itemList.get(4).getDateTaken(), + /* rowId for the last item of the previous page */ itemList.get(4).getRowId()); + + // Now set pagination parameters and get items. Since all items have the same time + // taken + // the pagination would be based on rowIDs. + // Files after the middle item should be returned. + final Cursor res2 = mItemsProvider.getAllItems(Category.DEFAULT, + paginationParameters, /* mimeType */ null, /* userId */ null, + /* cancellationSignal */ null); + assertThat(res2).isNotNull(); + // Only 5 items should be returned. + assertThat(res2.getCount()).isEqualTo(5); + + // Verify that the second half of the expected list has been returned. + int itr = 5; + while (res2.moveToNext()) { + assertThat(Item.fromCursor(res2, UserId.CURRENT_USER).compareTo( + itemList.get(itr))).isEqualTo(0); + itr++; } + // Ensure all items were verified. + assertThat(itr).isEqualTo(10); + + res2.moveToPosition(0); + assertThatOnlyImagesVideos(res2); } finally { - deleteAllFilesNoThrow(); + for (File file : imageFiles) { + file.delete(); + } } } /** - * Tests {@link {@link ItemsProvider#getAllItems(Category, int, String[], UserId)}} does not + * Tests {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, int, String[], UserId)} to return paginated items. + */ + @Test + public void testGetItems_withPagination_differentTimeModified() throws Exception { + // Create 10 new files, all with different time taken. + List<File> imageFiles = assertCreateNewImagesWithDifferentDateModifiedTimes( + /* number of images */ 10); + try { + // all files should be returned. + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, /* cancellationSignal */ null); + assertThat(res).isNotNull(); + assertThat(res.getCount()).isEqualTo(10); + // create a list from the cursor. + List<Item> itemList = new ArrayList<>(10); + while (res.moveToNext()) { + Item item = Item.fromCursor(res, UserId.CURRENT_USER); + itemList.add(item); + } + res.moveToPosition(0); + assertThatOnlyImagesVideos(res); + + // For this test, paginate the above list by returning second half of the items using + // the pagingParameters created by the middle item of the above list. + PaginationParameters paginationParameters = new PaginationParameters( + /* pageSize */ 5, + /* dateTaken for the last item of the previous page */ + itemList.get(4).getDateTaken(), + /* rowId for the last item of the previous page */ itemList.get(4).getRowId()); + + // Now set pagination parameters and get items. + // Files after the middle item should be returned. + final Cursor res2 = mItemsProvider.getAllItems(Category.DEFAULT, + paginationParameters, /* mimeType */ null, /* userId */ null, + /* cancellationSignal */ null); + assertThat(res2).isNotNull(); + // Only 5 items should be returned. + assertThat(res2.getCount()).isEqualTo(5); + + // Verify that the second half of the expected list has been returned. + int itr = 5; + while (res2.moveToNext()) { + assertThat(Item.fromCursor(res2, UserId.CURRENT_USER).compareTo( + itemList.get(itr))).isEqualTo(0); + itr++; + } + // Ensure all items were verified. + assertThat(itr).isEqualTo(10); + + res2.moveToPosition(0); + assertThatOnlyImagesVideos(res2); + } finally { + for (File file : imageFiles) { + file.delete(); + } + } + } + + /** + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], + * UserId)} (Category, PaginationParameters, String[], UserId)} (Category, int, String[], + * UserId)}} does not * return hidden images/videos. */ @Test @@ -487,8 +693,9 @@ public class ItemsProviderTest { File imageFileHidden = assertCreateNewImage(hiddenDir); File videoFileHidden = assertCreateNewVideo(hiddenDir); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ null, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ null, /* userId */ null, /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(0); } finally { @@ -499,7 +706,10 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} to return all + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} (Category, int, String[], UserId)} + * to return all * images and videos based on the mimeType. Image mimeType should only return images. */ @Test @@ -509,8 +719,10 @@ public class ItemsProviderTest { File imageFile = assertCreateNewImage(); File videoFile = assertCreateNewVideo(); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{ "image/*"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"image/*"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(1); @@ -523,7 +735,10 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} to return all + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} (Category, int, String[], UserId)} + * to return all * images and videos based on the mimeType. Image mimeType should only return images. */ @Test @@ -531,8 +746,10 @@ public class ItemsProviderTest { // Create a jpg file image. Tests negative use case, this should not be returned below. File imageFile = assertCreateNewImage(); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{"image/png"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"image/png"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(0); } finally { @@ -541,7 +758,10 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} does not return + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} (Category, int, String[], UserId)} + * does not return * hidden images/videos. */ @Test @@ -552,8 +772,10 @@ public class ItemsProviderTest { File imageFileHidden = assertCreateNewImage(hiddenDir); File videoFileHidden = assertCreateNewVideo(hiddenDir); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{"image/*"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"image/*"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(0); } finally { @@ -564,7 +786,111 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} to return all + * Tests {@link ItemsProvider#getLocalItemsForSelection(Category, List, String[], + * UserId, CancellationSignal)} to return only selected items from the media table for ids + * defined in the localId selection list. + */ + @Test + public void testGetItemsImages_withLocalIdSelection() throws Exception { + List<Uri> imageFilesUris = assertCreateNewImagesWithSameDateModifiedTimesAndReturnUri(10); + // Put the id of random items from the inserted set. say 4th and 6th item. + ArrayList<Long> inputIds = new ArrayList<>(1); + inputIds.add(ContentUris.parseId(imageFilesUris.get(4))); + inputIds.add(ContentUris.parseId(imageFilesUris.get(6))); + ArrayList<Integer> inputIdsAsIntegers = + (ArrayList<Integer>) inputIds.stream().map( + (Long id) -> Integer.valueOf(Math.toIntExact(id))).collect( + Collectors.toList()); + try { + // get the item objects for the provided ids. + final Cursor res = mItemsProvider.getLocalItemsForSelection(Category.DEFAULT, + /* local id selection list */ inputIdsAsIntegers, + /* mimeType */ new String[]{"image/*"}, /* userId */ null, + /* cancellationSignal */ null); + + // verify that the correct number of items are returned and that they have the correct + // ids. + assertThat(res).isNotNull(); + assertThat(res.getCount()).isEqualTo(2); + res.moveToPosition(0); + while (res.moveToNext()) { + Item item = Item.fromCursor(res, UserId.CURRENT_USER); + assertTrue(inputIds.contains(Long.parseLong(item.getId()))); + } + assertThatOnlyImages(res); + } finally { + // clean up. + deleteAllFilesNoThrow(); + } + } + + /** + * Tests {@link ItemsProvider#getLocalItemsForSelection(Category, List, String[], + * UserId, CancellationSignal)} to return only selected items from the media table for ids + * defined in the localId selection list. + */ + @Test + public void testGetItemsImages_withLocalIdSelection_largeDataSet() throws Exception { + List<Uri> imageFilesUris = assertCreateNewImagesWithSameDateModifiedTimesAndReturnUri(200); + // Try to fetch all items via selection. 200 items, this will hit the split query and + // verify that it is working. + List<Integer> inputIdsAsIntegers = imageFilesUris.stream().map(ContentUris::parseId).map( + Long::intValue).collect(Collectors.toList()); + try { + // get the item objects for the provided ids. + final Cursor res = mItemsProvider.getLocalItemsForSelection(Category.DEFAULT, + /* local id selection list */ inputIdsAsIntegers, + /* mimeType */ new String[]{"image/*"}, /* userId */ null, + /* cancellationSignal */ null); + + // verify that the correct number of items are returned and that they have the correct + // ids. + assertThat(res).isNotNull(); + assertThat(res.getCount()).isEqualTo(inputIdsAsIntegers.size()); + res.moveToPosition(0); + while (res.moveToNext()) { + Item item = Item.fromCursor(res, UserId.CURRENT_USER); + assertTrue(inputIdsAsIntegers.contains(Integer.parseInt(item.getId()))); + } + assertThatOnlyImages(res); + } finally { + // clean up. + deleteAllFilesNoThrow(); + } + } + + /** + * Tests {@link ItemsProvider#getLocalItemsForSelection(Category, List, String[], + * UserId, CancellationSignal)} to return only selected items from the media table for ids + * defined in the localId selection list. Here the list is empty so the parameter is ignored and + * the list is returned without any selection. + */ + @Test + public void testGetItemsImages_withLocalIdSelectionEmpty() throws Exception { + assertCreateNewImagesWithSameDateModifiedTimesAndReturnUri(10); + try { + // get the item objects for the empty list. + final Cursor res = mItemsProvider.getLocalItemsForSelection(Category.DEFAULT, + /* local id selection list */ new ArrayList<>(), + /* mimeType */ new String[]{"image/*"}, /* userId */ null, + /* cancellationSignal */ null); + + assertThat(res).isNotNull(); + // All images are returned and selection is ignored. + assertThat(res.getCount()).isEqualTo(10); + assertThatOnlyImages(res); + } finally { + // clean up. + deleteAllFilesNoThrow(); + } + } + + + /** + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} (Category, int, String[], UserId)} + * to return all * images and videos based on the mimeType. Video mimeType should only return videos. */ @Test @@ -574,8 +900,10 @@ public class ItemsProviderTest { File imageFile = assertCreateNewImage(); File videoFile = assertCreateNewVideo(); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{"video/*"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"video/*"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(1); @@ -588,7 +916,10 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} to return all + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} (Category, int, String[], UserId)} + * to return all * images and videos based on the mimeType. Image mimeType should only return images. */ @Test @@ -596,8 +927,10 @@ public class ItemsProviderTest { // Create a mp4 video file. Tests positive use case, this should be returned below. File videoFile = assertCreateNewVideo(); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{"video/mp4"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"video/mp4"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(1); } finally { @@ -606,7 +939,9 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getAllItems(Category, int, String[], UserId)} does not return + * Tests + * {@link ItemsProvider#getAllItems(Category, PaginationParameters, String[], UserId)} + * (Category, PaginationParameters, String[], UserId)} does not return * hidden images/videos. */ @Test @@ -617,8 +952,10 @@ public class ItemsProviderTest { File imageFileHidden = assertCreateNewImage(hiddenDir); File videoFileHidden = assertCreateNewVideo(hiddenDir); try { - final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, /* limit */ -1, - /* mimeType */ new String[]{"video/*"}, /* userId */ null); + final Cursor res = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), + /* mimeType */ new String[]{"video/*"}, /* userId */ null, + /* cancellationSignal */ null); assertThat(res).isNotNull(); assertThat(res.getCount()).isEqualTo(0); @@ -630,23 +967,29 @@ public class ItemsProviderTest { } /** - * Tests {@link ItemsProvider#getLocalItems(Category, int, String[], UserId)} to returns only + * Tests + * {@link ItemsProvider#getLocalItems(Category, PaginationParameters, String[], UserId)} + * to returns only * local content. */ @Test - public void testGetLocalItems_withCloud() throws Exception { + public void testGetLocalItems_withCloudFeatureOn() throws Exception { File videoFile = assertCreateNewVideo(); try { mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( sTargetPackageName); - // Init cloud provider and add one item - setupCloudProvider((cloudMediaGenerator) -> { - cloudMediaGenerator.addMedia(null, "cloud_id1"); - }); - - // Verify that getLocalItems includes only local contents - try (Cursor c = mItemsProvider.getLocalItems(Category.DEFAULT, -1, new String[]{}, - UserId.CURRENT_USER)) { + // Init cloud provider with no items. We cannot test for cloud items because + // getAllItems query does not block on cloud sync. + setupCloudProvider((cloudMediaGenerator) -> {}); + mItemsProvider.initPhotoPickerData(/* albumId */ null, + /* albumAuthority */ null, + /*initLocalOnlyData */ false, + UserId.CURRENT_USER); + + // Verify that getLocalItems includes all local contents + try (Cursor c = mItemsProvider.getLocalItems(Category.DEFAULT, + new PaginationParameters(), new String[]{}, + UserId.CURRENT_USER, /* cancellationSignal */ null)) { assertThat(c.getCount()).isEqualTo(1); assertThat(c.moveToFirst()).isTrue(); @@ -654,21 +997,17 @@ public class ItemsProviderTest { .isEqualTo(LOCAL_PICKER_PROVIDER_AUTHORITY); } - // Verify that getAllItems includes cloud items - try (Cursor c = mItemsProvider.getAllItems(Category.DEFAULT, -1, new String[]{}, - UserId.CURRENT_USER)) { - assertThat(c.getCount()).isEqualTo(2); + // Verify that getAllItems also includes local items. We cannot check for cloud items + // because getAllItems query does not block on cloud sync. + try (Cursor c = mItemsProvider.getAllItems(Category.DEFAULT, + new PaginationParameters(), new String[]{}, + UserId.CURRENT_USER, + /* cancellationSignal */ null)) { + assertThat(c.getCount()).isEqualTo(1); // Verify that the first item is cloud item assertThat(c.moveToFirst()).isTrue(); assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.AUTHORITY))) - .isEqualTo(CloudProviderPrimary.AUTHORITY); - assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.ID))).isEqualTo( - "cloud_id1"); - - // Verify that the second item is local item - assertThat(c.moveToNext()).isTrue(); - assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.AUTHORITY))) .isEqualTo(LOCAL_PICKER_PROVIDER_AUTHORITY); } } finally { @@ -679,41 +1018,42 @@ public class ItemsProviderTest { } @Test - public void testGetLocalItems_mergedAlbum_withCloud() throws Exception { + public void testGetLocalItems_mergedAlbum_withCloudFeatureOn() throws Exception { File videoFile = assertCreateNewVideo(); Category videoAlbum = new Category(CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS, LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 10, true); try { mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( sTargetPackageName); - // Init cloud provider and add one item - setupCloudProvider((cloudMediaGenerator) -> { - cloudMediaGenerator.addMedia(null, "cloud_id1", null, "video/mp4", 0, 1024, false); - }); - - // Verify that getLocalItems for merged album "Video" includes only local contents - try (Cursor c = mItemsProvider.getLocalItems(videoAlbum, -1, new String[]{}, - UserId.CURRENT_USER)) { + // Init cloud provider with no items. We cannot test for cloud items because + // getAllItems query does not block on cloud sync. + setupCloudProvider((cloudMediaGenerator) -> {}); + mItemsProvider.initPhotoPickerData(/* albumId */ null, + /* albumAuthority */ null, + /*initLocalOnlyData */ false, + UserId.CURRENT_USER); + + // Verify that getLocalItems for merged album "Video" includes all local contents + try (Cursor c = mItemsProvider.getLocalItems(videoAlbum, + new PaginationParameters(), new String[]{}, + UserId.CURRENT_USER, /* cancellationSignal */ null)) { assertThat(c.getCount()).isEqualTo(1); assertThat(c.moveToFirst()).isTrue(); assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.AUTHORITY))) .isEqualTo(LOCAL_PICKER_PROVIDER_AUTHORITY); } - // Verify that getAllItems for merged album "Video" also includes cloud contents - try (Cursor c = mItemsProvider.getAllItems(videoAlbum, -1, new String[]{}, - UserId.CURRENT_USER)) { - assertThat(c.getCount()).isEqualTo(2); + // Verify that getAllItems for merged album "Video" also includes all local contents. + // We cannot check for cloud items because getAllItems query does not block on cloud + // sync. + try (Cursor c = mItemsProvider.getAllItems(videoAlbum, new PaginationParameters(), + new String[]{}, + UserId.CURRENT_USER, + /* cancellationSignal */ null)) { + assertThat(c.getCount()).isEqualTo(1); // Verify that the first item is cloud item assertThat(c.moveToFirst()).isTrue(); assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.AUTHORITY))) - .isEqualTo(CloudProviderPrimary.AUTHORITY); - assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.ID))) - .isEqualTo("cloud_id1"); - - // Verify that the second item is local item - assertThat(c.moveToNext()).isTrue(); - assertThat(c.getString(c.getColumnIndexOrThrow(MediaColumns.AUTHORITY))) .isEqualTo(LOCAL_PICKER_PROVIDER_AUTHORITY); } } finally { @@ -724,7 +1064,7 @@ public class ItemsProviderTest { } @Test - public void testGetLocalCategories_withCloud() throws Exception { + public void testGetLocalCategories_withCloudFeatureOn() throws Exception { File videoFile = assertCreateNewVideo(getMoviesDir()); File screenshotFile = assertCreateNewImage(getScreenshotsDir()); final String cloudAlbum = "testAlbum"; @@ -739,10 +1079,14 @@ public class ItemsProviderTest { false); cloudMediaGenerator.createAlbum(cloudAlbum); }); + mItemsProvider.initPhotoPickerData(/* albumId */ null, + /* albumAuthority */ null, + /*initLocalOnlyData */ false, + UserId.CURRENT_USER); // Verify that getLocalCategories only returns local albums try (Cursor c = mItemsProvider.getLocalCategories(/* mimeType */ null, - /* userId */ null)) { + /* userId */ null, /* cancellationSignal*/ null)) { assertGetCategoriesMatchMultiple(c, Arrays.asList( Pair.create(ALBUM_ID_VIDEOS, 1), Pair.create(ALBUM_ID_SCREENSHOTS, 1) @@ -752,8 +1096,9 @@ public class ItemsProviderTest { // Verify that getAllCategories returns local + cloud albums try (Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, - /* userId */ null)) { + /* userId */ null, /* cancellationSignal*/ null)) { assertGetCategoriesMatchMultiple(c, Arrays.asList( + Pair.create(ALBUM_ID_FAVORITES, 0), Pair.create(ALBUM_ID_VIDEOS, 2), Pair.create(ALBUM_ID_SCREENSHOTS, 1), Pair.create(cloudAlbum, 1) @@ -799,7 +1144,8 @@ public class ItemsProviderTest { return; } - Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); assertThat(c).isNotNull(); assertThat(c.getCount()).isEqualTo(1); @@ -830,7 +1176,8 @@ public class ItemsProviderTest { } private void assertCategoriesNoMatch(String expectedCategoryName) { - try (Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null)) { + try (Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null)) { while (c != null && c.moveToNext()) { final int nameColumnIndex = c.getColumnIndexOrThrow(AlbumColumns.DISPLAY_NAME); final String categoryName = c.getString(nameColumnIndex); @@ -840,7 +1187,8 @@ public class ItemsProviderTest { } private void assertGetCategoriesMatchMultiple(List<Pair<String, Integer>> categories) { - try (Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null)) { + try (Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null)) { assertGetCategoriesMatchMultiple(c, categories); } } @@ -960,6 +1308,42 @@ public class ItemsProviderTest { } } + private List<File> assertCreateNewImagesWithDifferentDateModifiedTimes(int numberOfImages) + throws Exception { + List<File> imageFiles = new ArrayList<>(); + for (int itr = 0; itr < numberOfImages; itr++) { + String fileName = TAG + "_file_" + String.valueOf(System.nanoTime()) + ".jpg"; + imageFiles.add(assertCreateNewFileWithLastModifiedTime(getDownloadsDir(), fileName, + System.nanoTime() / 1000)); + } + return imageFiles; + } + + private List<File> assertCreateNewImagesWithSameDateModifiedTimes(int numberOfImages) + throws Exception { + List<File> imageFiles = new ArrayList<>(); + long currentTime = System.nanoTime() / 1000; + for (int itr = 0; itr < numberOfImages; itr++) { + String fileName = TAG + "_file_" + String.valueOf(System.nanoTime()) + ".jpg"; + imageFiles.add(assertCreateNewFileWithLastModifiedTime(getDownloadsDir(), fileName, + currentTime)); + } + return imageFiles; + } + + + private List<Uri> assertCreateNewImagesWithSameDateModifiedTimesAndReturnUri(int numberOfImages) + throws Exception { + List<Uri> imageFiles = new ArrayList<>(); + long currentTime = System.nanoTime() / 1000; + for (int itr = 0; itr < numberOfImages; itr++) { + String fileName = TAG + "_file_" + String.valueOf(System.nanoTime()) + ".jpg"; + imageFiles.add(assertCreateNewFileWithLastModifiedTimeAndReturnUri( + getDownloadsDir(), fileName, currentTime)); + } + return imageFiles; + } + private File assertCreateNewVideo(File dir) throws Exception { return assertCreateNewFile(dir, VIDEO_FILE_NAME); } @@ -983,6 +1367,19 @@ public class ItemsProviderTest { return file; } + private File assertCreateNewFileWithLastModifiedTime(File parentDir, String fileName, + long lastModifiedTime) throws Exception { + final File file = new File(parentDir, fileName); + prepareFileAndGetUri(file, lastModifiedTime); + return file; + } + private Uri assertCreateNewFileWithLastModifiedTimeAndReturnUri(File parentDir, String fileName, + long lastModifiedTime) throws Exception { + final File file = new File(parentDir, fileName); + return prepareFileAndGetUri(file, lastModifiedTime); + } + + private Uri prepareFileAndGetUri(File file, long lastModifiedTime) throws IOException { ensureParentExists(file.getParentFile()); @@ -1062,8 +1459,8 @@ public class ItemsProviderTest { private void deleteAllFilesNoThrow() { try (Cursor c = mIsolatedResolver.query( MediaStore.Files.getContentUri(VOLUME_EXTERNAL), - new String[] {MediaStore.MediaColumns.DATA}, null, null)) { - while(c.moveToNext()) { + new String[]{MediaStore.MediaColumns.DATA}, null, null)) { + while (c.moveToNext()) { (new File(c.getString( c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)))).delete(); } diff --git a/tests/src/com/android/providers/media/photopicker/LocalProvider.java b/tests/src/com/android/providers/media/photopicker/LocalProvider.java index b1c1281a3..09c82b1fc 100644 --- a/tests/src/com/android/providers/media/photopicker/LocalProvider.java +++ b/tests/src/com/android/providers/media/photopicker/LocalProvider.java @@ -52,7 +52,7 @@ public class LocalProvider extends CloudMediaProvider { CloudProviderQueryExtras.fromCloudMediaBundle(extras); return mMediaGenerator.getMedia(queryExtras.getGeneration(), queryExtras.getAlbumId(), - queryExtras.getMimeTypes(), queryExtras.getSizeBytes()); + queryExtras.getMimeTypes(), queryExtras.getSizeBytes(), queryExtras.getPageSize()); } @Override diff --git a/tests/src/com/android/providers/media/photopicker/NotificationContentObserverTest.java b/tests/src/com/android/providers/media/photopicker/NotificationContentObserverTest.java new file mode 100644 index 000000000..7d3084cbf --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/NotificationContentObserverTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.net.Uri; + +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; + +@RunWith(AndroidJUnit4.class) +public class NotificationContentObserverTest { + private static final String URI_UPDATE_MEDIA = "content://media/picker_internal/update/media"; + private static final String URI_UPDATE_ALBUM_CONTENT = + "content://media/picker_internal/update/album_content"; + + private static final String KEY_MEDIA = "media"; + private static final String KEY_ALBUM_CONTENT = "album_content"; + private final NotificationContentObserver.ContentObserverCallback mObserverCallbackA = + spy(new TestableContentObserverCallback()); + private final NotificationContentObserver.ContentObserverCallback mObserverCallbackB = + spy(new TestableContentObserverCallback()); + + private NotificationContentObserver mObserver; + + @Before + public void setUp() { + mObserver = new NotificationContentObserver(null); + } + + @Test + public void registerKeysToObserverCallback_correctKeys_registersCallback() { + mObserver.registerKeysToObserverCallback(Arrays.asList(KEY_MEDIA), mObserverCallbackA); + mObserver.registerKeysToObserverCallback( + Arrays.asList(KEY_ALBUM_CONTENT), mObserverCallbackB); + + assertThat(mObserver.getUrisToCallback()).hasSize(2); + assertThat(mObserver.getUrisToCallback()) + .containsEntry(Arrays.asList(KEY_MEDIA), mObserverCallbackA); + assertThat(mObserver.getUrisToCallback()) + .containsEntry(Arrays.asList(KEY_ALBUM_CONTENT), mObserverCallbackB); + + mObserver.registerKeysToObserverCallback( + Arrays.asList(KEY_MEDIA, KEY_ALBUM_CONTENT), mObserverCallbackB); + + assertThat(mObserver.getUrisToCallback()).hasSize(3); + assertThat(mObserver.getUrisToCallback()).containsEntry( + Arrays.asList(KEY_MEDIA, KEY_ALBUM_CONTENT), mObserverCallbackB); + } + + @Test + public void registerKeysToObserverCallback_incorrectKey_doesNotRegisterCallback() { + mObserver.registerKeysToObserverCallback(Arrays.asList("invalid_key"), mObserverCallbackA); + + assertThat(mObserver.getUrisToCallback()).hasSize(0); + } + + @Test + public void registerKeysToObserverCallback_atLeastOneValidKey_registersCallback() { + mObserver.registerKeysToObserverCallback( + Arrays.asList(KEY_ALBUM_CONTENT, "invalid_key"), mObserverCallbackB); + + assertThat(mObserver.getUrisToCallback()).hasSize(1); + } + + @Test + public void onChange_receivesCorrectMediaUri_invokesCallback() { + mObserver.registerKeysToObserverCallback(Arrays.asList(KEY_MEDIA), mObserverCallbackA); + String timestamp = "1063"; + + mObserver.onChange(false, Uri.parse(URI_UPDATE_MEDIA + "/" + timestamp)); + + verify(mObserverCallbackA).onNotificationReceived(timestamp, null); + } + + @Test + public void onChange_receivesCorrectAlbumContentUri_invokesCallback() { + mObserver.registerKeysToObserverCallback( + Arrays.asList(KEY_ALBUM_CONTENT), mObserverCallbackB); + String albumId = "10"; + String timestamp = "457801"; + + mObserver.onChange(false, Uri.parse(URI_UPDATE_ALBUM_CONTENT + + "/" + albumId + "/" + timestamp)); + + verify(mObserverCallbackB).onNotificationReceived(timestamp, albumId); + } + + @Test + public void onChange_receivesIncorrectUri_doesNotInvokeCallback() { + mObserver.registerKeysToObserverCallback( + Arrays.asList(KEY_ALBUM_CONTENT), mObserverCallbackB); + String timestamp = "12345"; + + // Missing ablum-id + mObserver.onChange(false, Uri.parse(URI_UPDATE_ALBUM_CONTENT + "/" + timestamp)); + + verify(mObserverCallbackB, never()).onNotificationReceived(timestamp, null); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java b/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java index 2524e6832..5f6f26961 100644 --- a/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java +++ b/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java @@ -22,10 +22,16 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_ import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LONG_DEFAULT; import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.STRING_DEFAULT; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + import android.content.Context; import android.content.Intent; import android.database.Cursor; @@ -35,23 +41,34 @@ import android.provider.CloudMediaProviderContract.MediaColumns; import android.provider.MediaStore; import android.util.Pair; +import androidx.annotation.NonNull; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import androidx.work.WorkManager; import com.android.modules.utils.BackgroundThread; import com.android.providers.media.PickerProviderMediaGenerator; import com.android.providers.media.TestConfigStore; +import com.android.providers.media.photopicker.data.CloudProviderInfo; import com.android.providers.media.photopicker.data.PickerDatabaseHelper; import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.data.PickerSyncRequestExtras; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.sync.PickerSyncManager; +import com.android.providers.media.util.ForegroundThread; import org.junit.After; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import java.io.File; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; @RunWith(AndroidJUnit4.class) public class PickerDataLayerTest { @@ -103,6 +120,7 @@ public class PickerDataLayerTest { private PickerDbFacade mFacade; private PickerDataLayer mDataLayer; private PickerSyncController mController; + private TestConfigStore mConfigStore; @Before public void setUp() { @@ -120,16 +138,22 @@ public class PickerDataLayerTest { final File dbPath = mContext.getDatabasePath(DB_NAME); dbPath.delete(); + final PickerSyncLockManager lockManager = new PickerSyncLockManager(); + mDbHelper = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_1); - mFacade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, mDbHelper); + mFacade = new PickerDbFacade(mContext, lockManager, LOCAL_PROVIDER_AUTHORITY, mDbHelper); + + mConfigStore = new TestConfigStore(); + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); - final TestConfigStore configStore = new TestConfigStore(); - configStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); - configStore.setPickerSyncDelayMs(0); + mController = PickerSyncController.initialize( + mContext, mFacade, mConfigStore, lockManager, LOCAL_PROVIDER_AUTHORITY); - mController = new PickerSyncController( - mContext, mFacade, configStore, LOCAL_PROVIDER_AUTHORITY); - mDataLayer = new PickerDataLayer(mContext, mFacade, mController); + initializeTestWorkManager(mContext); + final WorkManager workManager = WorkManager.getInstance(mContext); + final PickerSyncManager syncManager = new PickerSyncManager( + workManager, mContext, mConfigStore, /* schedulePeriodicSyncs */ false); + mDataLayer = new PickerDataLayer(mContext, mFacade, mController, mConfigStore, syncManager); // Set cloud provider to null to discard mFacade.setCloudProvider(null); @@ -149,6 +173,8 @@ public class PickerDataLayerTest { addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(buildDefaultQueryArgs())) { assertThat(cr.getCount()).isEqualTo(2); @@ -172,6 +198,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(defaultQueryArgs)) { assertThat(cr.getCount()).isEqualTo(4); @@ -203,6 +231,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(defaultQueryArgs)) { assertThat(cr.getCount()).isEqualTo(4); @@ -233,6 +263,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(defaultQueryArgs)) { assertThat(cr.getCount()).isEqualTo(4); @@ -263,6 +295,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(defaultQueryArgs)) { assertThat(cr.getCount()).isEqualTo(4); @@ -286,6 +320,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle queryArgs = buildQueryArgs(IMAGE_MIME_TYPE, SIZE_BYTES_DEFAULT); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(queryArgs)) { assertThat(cr.getCount()).isEqualTo(1); @@ -305,6 +341,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle queryArgs = buildQueryArgs(IMAGE_MIME_TYPE, SIZE_BYTES - 1); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(queryArgs)) { assertThat(cr.getCount()).isEqualTo(1); @@ -327,6 +365,8 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle queryArgs = buildQueryArgs(VIDEO_MIME_TYPE, SIZE_BYTES - 1); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(queryArgs)) { assertThat(cr.getCount()).isEqualTo(1); @@ -343,6 +383,9 @@ public class PickerDataLayerTest { addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); Bundle queryArgs = buildDefaultQueryArgs(); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); + // Verify that we only see local content try (Cursor cr = mDataLayer.fetchLocalMedia(queryArgs)) { assertThat(cr.getCount()).isEqualTo(1); @@ -360,6 +403,7 @@ public class PickerDataLayerTest { } @Test + @Ignore("Enable when b/293112236 is done") public void testFetchAlbumMedia() { mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -377,6 +421,7 @@ public class PickerDataLayerTest { final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + mDataLayer.initMediaData(buildDefaultSyncRequestExtras()); try (Cursor cr = mDataLayer.fetchAllAlbums(defaultQueryArgs)) { assertThat(cr.getCount()).isEqualTo(4); @@ -402,25 +447,26 @@ public class PickerDataLayerTest { final Bundle localAlbumQueryArgs = buildQueryArgs(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES_DEFAULT); - - final Bundle cloudAlbumQueryArgs = buildQueryArgs(ALBUM_ID_2, - CLOUD_PRIMARY_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES_DEFAULT); - - final Bundle favoriteAlbumQueryArgs = buildQueryArgs(ALBUM_ID_FAVORITES, - LOCAL_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES_DEFAULT); - + mDataLayer.initMediaData(buildSyncRequestExtras(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(localAlbumQueryArgs)) { assertWithMessage("Local album count").that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } + final Bundle cloudAlbumQueryArgs = buildQueryArgs(ALBUM_ID_2, + CLOUD_PRIMARY_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES_DEFAULT); + mDataLayer.initMediaData( + buildSyncRequestExtras(ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(cloudAlbumQueryArgs)) { assertWithMessage("Cloud album count").that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } + final Bundle favoriteAlbumQueryArgs = buildQueryArgs(ALBUM_ID_FAVORITES, + LOCAL_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES_DEFAULT); + mDataLayer.initMediaData(buildDefaultSyncRequestExtras()); try (Cursor cr = mDataLayer.fetchAllMedia(favoriteAlbumQueryArgs)) { assertWithMessage("Favorite album count").that(cr.getCount()).isEqualTo(2); @@ -430,6 +476,7 @@ public class PickerDataLayerTest { } @Test + @Ignore("Enable when b/293112236 is done") public void testFetchAlbumMediaMimeTypeFilter() { mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -446,26 +493,32 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle mimeTypeQueryArgs = buildQueryArgs(IMAGE_MIME_TYPE, SIZE_BYTES_DEFAULT); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllAlbums(mimeTypeQueryArgs)) { - assertThat(cr.getCount()).isEqualTo(2); + assertThat(cr.getCount()).isEqualTo(4); + // Favorites and Videos merged albums will be always visible + assertAlbumCursor(cr, ALBUM_ID_FAVORITES, LOCAL_PROVIDER_AUTHORITY); + assertAlbumCursor(cr, ALBUM_ID_VIDEOS, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } final Bundle localAlbumAndMimeTypeQueryArgs = buildQueryArgs(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY, IMAGE_MIME_TYPE, SIZE_BYTES_DEFAULT); - - final Bundle cloudAlbumAndMimeTypeQueryArgs = buildQueryArgs(ALBUM_ID_2, - CLOUD_PRIMARY_PROVIDER_AUTHORITY, IMAGE_MIME_TYPE, SIZE_BYTES_DEFAULT); - + mDataLayer.initMediaData(buildSyncRequestExtras(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(localAlbumAndMimeTypeQueryArgs)) { assertWithMessage("Local album count").that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } + final Bundle cloudAlbumAndMimeTypeQueryArgs = buildQueryArgs(ALBUM_ID_2, + CLOUD_PRIMARY_PROVIDER_AUTHORITY, IMAGE_MIME_TYPE, SIZE_BYTES_DEFAULT); + mDataLayer.initMediaData( + buildSyncRequestExtras(ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(cloudAlbumAndMimeTypeQueryArgs)) { assertWithMessage("Cloud album count").that(cr.getCount()).isEqualTo(1); @@ -474,6 +527,7 @@ public class PickerDataLayerTest { } @Test + @Ignore("Enable when b/293112236 is done") public void testFetchAlbumMediaSizeFilter() { mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -492,10 +546,14 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle sizeQueryArgs = buildQueryArgs(MIME_TYPE_DEFAULT, SIZE_BYTES - 1); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllAlbums(sizeQueryArgs)) { - assertThat(cr.getCount()).isEqualTo(3); + assertThat(cr.getCount()).isEqualTo(4); + // Favorites and Videos merged albums will be always visible + assertAlbumCursor(cr, ALBUM_ID_FAVORITES, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_VIDEOS, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -503,16 +561,17 @@ public class PickerDataLayerTest { final Bundle localAlbumAndSizeQueryArgs = buildQueryArgs(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES -1); - - final Bundle cloudAlbumAndSizeQueryArgs = buildQueryArgs(ALBUM_ID_2, - CLOUD_PRIMARY_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES -1); - + mDataLayer.initMediaData(buildSyncRequestExtras(ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(localAlbumAndSizeQueryArgs)) { assertWithMessage("Local album count").that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); } + final Bundle cloudAlbumAndSizeQueryArgs = buildQueryArgs(ALBUM_ID_2, + CLOUD_PRIMARY_PROVIDER_AUTHORITY, MIME_TYPE_DEFAULT, SIZE_BYTES - 1); + mDataLayer.initMediaData( + buildSyncRequestExtras(ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY)); try (Cursor cr = mDataLayer.fetchAllMedia(cloudAlbumAndSizeQueryArgs)) { assertWithMessage("Cloud album count").that(cr.getCount()).isEqualTo(1); @@ -521,6 +580,7 @@ public class PickerDataLayerTest { } @Test + @Ignore("Enable when b/293112236 is done") public void testFetchAlbumMediaMimeTypeAndSizeFilter() { mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -539,21 +599,27 @@ public class PickerDataLayerTest { MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE, SIZE_BYTES, /* isFavorite */ false); final Bundle mimeTypeAndSizeQueryArgs = buildQueryArgs(VIDEO_MIME_TYPE, SIZE_BYTES -1); - - final Bundle cloudAlbumAndMimeTypeQueryArgs = buildQueryArgs(ALBUM_ID_2, - CLOUD_PRIMARY_PROVIDER_AUTHORITY, VIDEO_MIME_TYPE, SIZE_BYTES - 1); + final PickerSyncRequestExtras syncRequestExtras = buildDefaultSyncRequestExtras(); + mDataLayer.initMediaData(syncRequestExtras); try (Cursor cr = mDataLayer.fetchAllAlbums(mimeTypeAndSizeQueryArgs)) { - assertWithMessage("Merged and Local album count").that(cr.getCount()).isEqualTo(3); + assertWithMessage("Merged and Local album count").that(cr.getCount()).isEqualTo(4); // Most recent video will be the cover of the Videos album. In this scenario, Videos // album cover was generated with cloud authority, so the Videos album authority should // be cloud provider authority. + // Favorites and Videos album will always be displayed. + assertAlbumCursor(cr, ALBUM_ID_FAVORITES, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_VIDEOS, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_1, LOCAL_PROVIDER_AUTHORITY); assertAlbumCursor(cr, ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } + final Bundle cloudAlbumAndMimeTypeQueryArgs = buildQueryArgs(ALBUM_ID_2, + CLOUD_PRIMARY_PROVIDER_AUTHORITY, VIDEO_MIME_TYPE, SIZE_BYTES - 1); + final PickerSyncRequestExtras cloudSyncRequestExtras = + buildSyncRequestExtras(ALBUM_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + mDataLayer.initMediaData(cloudSyncRequestExtras); try (Cursor cr = mDataLayer.fetchAllMedia(cloudAlbumAndMimeTypeQueryArgs)) { assertWithMessage("Cloud album count").that(cr.getCount()).isEqualTo(1); @@ -562,6 +628,7 @@ public class PickerDataLayerTest { } @Test + @Ignore("Enable when b/293112236 is done") public void testFetchAlbumMediaLocalOnly() { mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -582,8 +649,10 @@ public class PickerDataLayerTest { // Favorites - Merged Album - 2 files (1 local + 1 cloud) final Bundle defaultQueryArgs = buildDefaultQueryArgs(); + mDataLayer.initMediaData(buildDefaultSyncRequestExtras()); // Verify that we see both local and cloud albums try (Cursor cr = mDataLayer.fetchAllAlbums(defaultQueryArgs)) { + // Favorites and Videos merged albums will be always visible assertThat(cr.getCount()).isEqualTo(3); } @@ -640,6 +709,96 @@ public class PickerDataLayerTest { assertThat(info.accountConfigurationIntent).isEqualTo(expectedIntent); } + @Test + public void testInitMediaDataInvalidData() { + final Bundle syncExtrasBundle = new Bundle(); + syncExtrasBundle.putString(MediaStore.EXTRA_ALBUM_ID, "NotMergedAlbum"); + syncExtrasBundle.putString(MediaStore.EXTRA_ALBUM_AUTHORITY, "NotLocalAuthority"); + syncExtrasBundle.putBoolean(MediaStore.EXTRA_LOCAL_ONLY, true); + final PickerSyncRequestExtras syncExtras = + PickerSyncRequestExtras.fromBundle(syncExtrasBundle); + + assertThrows(IllegalStateException.class, + () -> mDataLayer.initMediaData(syncExtras)); + } + + @Test + public void testCloudPackageAllowlistListenerRemovesActiveThatIsNowInvalid() { + mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertThat(mController.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + + // Simulate a DeviceConfig change where the Allowlist is set to empty. + mConfigStore.setAllowedCloudProviderPackages(new String[] {}); + + + // The listener uses the ForegroundThread to run the listener, so wait for the + // ForegroundThread to complete. + ForegroundThread.waitForIdle(); + + assertThat(mController.getCurrentCloudProviderInfo()).isEqualTo(CloudProviderInfo.EMPTY); + } + + @Test + public void testCloudPackageAllowlistListenerDoesNotChangeAllowedProvider() { + mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertThat(mController.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + + // Simulate a DeviceConfig change where the Allowlist adds a new provider, but the current + // provider is still permitted. + final String newlyAddedProviderPackage = "com.hooli.super.awesome.cloud.provider"; + mConfigStore.setAllowedCloudProviderPackages( + new String[] {PACKAGE_NAME, newlyAddedProviderPackage}); + + // The listener uses the ForegroundThread to run the listener, so wait for the + // ForegroundThread to complete. + ForegroundThread.waitForIdle(); + + // Ensure nothing was changed. + assertThat(mController.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + } + + @Test + public void testWaitForSyncWhenSyncFutureIsComplete() + throws ExecutionException, InterruptedException { + final CompletableFuture<Void> completableFuture = new CompletableFuture<>(); + completableFuture.complete(null); + + final int inputRetryCount = 3; + assertThat(mDataLayer + .waitForSync(completableFuture, "work-name", inputRetryCount)) + .isEqualTo(inputRetryCount); + } + + @Test + public void testWaitForSyncWhenSyncFutureNeverCompletes() + throws ExecutionException, InterruptedException, TimeoutException { + final PickerSyncManager mockSyncManager = mock(PickerSyncManager.class); + final PickerDataLayer dataLayer = new PickerDataLayer(mContext, mFacade, mController, + mConfigStore, mockSyncManager); + final CompletableFuture<Void> completableFuture = new CompletableFuture<>(); + doReturn(true).when(mockSyncManager).isUniqueWorkPending(any()); + + final int inputRetryCount = 3; + assertThat(dataLayer + .waitForSync(completableFuture, "work-name", inputRetryCount)) + .isEqualTo(0); + } + + @Test + public void testWaitForSyncWhenWorkerFails() + throws ExecutionException, InterruptedException, TimeoutException { + final PickerSyncManager mockSyncManager = mock(PickerSyncManager.class); + final PickerDataLayer dataLayer = new PickerDataLayer(mContext, mFacade, mController, + mConfigStore, mockSyncManager); + final CompletableFuture<Void> completableFuture = new CompletableFuture<>(); + doReturn(false).when(mockSyncManager).isUniqueWorkPending(any()); + + final int inputRetryCount = 3; + assertThat(dataLayer + .waitForSync(completableFuture, "work-name", inputRetryCount)) + .isEqualTo(inputRetryCount); + } + private static void waitForIdle() { final CountDownLatch latch = new CountDownLatch(1); BackgroundThread.getExecutor().execute(() -> { @@ -678,6 +837,30 @@ public class PickerDataLayerTest { return queryArgs; } + @NonNull + private static PickerSyncRequestExtras buildDefaultSyncRequestExtras() { + return PickerSyncRequestExtras.fromBundle(buildDefaultSyncRequestBundle()); + } + + @NonNull + private static PickerSyncRequestExtras buildSyncRequestExtras(@NonNull String albumId, + @NonNull String albumAuthority) { + final Bundle syncRequestExtras = buildDefaultSyncRequestBundle(); + syncRequestExtras.putString(MediaStore.EXTRA_ALBUM_ID, albumId); + syncRequestExtras.putString(MediaStore.EXTRA_ALBUM_AUTHORITY, albumAuthority); + + return PickerSyncRequestExtras + .fromBundle(syncRequestExtras); + } + + @NonNull + private static Bundle buildDefaultSyncRequestBundle() { + final Bundle syncRequestExtras = new Bundle(); + syncRequestExtras.putBoolean(MediaStore.EXTRA_LOCAL_ONLY, false); + + return syncRequestExtras; + } + private static void addMedia(MediaGenerator generator, Pair<String, String> media) { generator.addMedia(media.first, media.second); } diff --git a/tests/src/com/android/providers/media/photopicker/PickerSyncControllerTest.java b/tests/src/com/android/providers/media/photopicker/PickerSyncControllerTest.java index 77f3bcab4..7408e4b23 100644 --- a/tests/src/com/android/providers/media/photopicker/PickerSyncControllerTest.java +++ b/tests/src/com/android/providers/media/photopicker/PickerSyncControllerTest.java @@ -17,34 +17,43 @@ package com.android.providers.media.photopicker; import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator; +import static com.android.providers.media.PickerUriResolver.REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI; +import static com.android.providers.media.photopicker.NotificationContentObserver.MEDIA; -import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.ContentResolver; import android.content.Context; import android.content.res.Resources; +import android.database.ContentObserver; import android.database.Cursor; +import android.os.Handler; import android.os.Process; -import android.os.SystemClock; import android.os.storage.StorageManager; import android.provider.CloudMediaProviderContract.MediaColumns; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; -import com.android.modules.utils.BackgroundThread; import com.android.providers.media.PickerProviderMediaGenerator; import com.android.providers.media.TestConfigStore; import com.android.providers.media.photopicker.data.CloudProviderInfo; import com.android.providers.media.photopicker.data.PickerDatabaseHelper; import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; import org.junit.After; import org.junit.Before; @@ -52,6 +61,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import java.io.File; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -60,6 +70,8 @@ import java.util.concurrent.TimeUnit; public class PickerSyncControllerTest { private static final String LOCAL_PROVIDER_AUTHORITY = "com.android.providers.media.photopicker.tests.local"; + private static final String FLAKY_CLOUD_PROVIDER_AUTHORITY = + "com.android.providers.media.photopicker.tests.cloud_flaky"; private static final String CLOUD_PRIMARY_PROVIDER_AUTHORITY = "com.android.providers.media.photopicker.tests.cloud_primary"; private static final String CLOUD_SECONDARY_PROVIDER_AUTHORITY = @@ -72,6 +84,8 @@ public class PickerSyncControllerTest { PickerProviderMediaGenerator.getMediaGenerator(CLOUD_PRIMARY_PROVIDER_AUTHORITY); private final MediaGenerator mCloudSecondaryMediaGenerator = PickerProviderMediaGenerator.getMediaGenerator(CLOUD_SECONDARY_PROVIDER_AUTHORITY); + private final MediaGenerator mCloudFlakyMediaGenerator = + PickerProviderMediaGenerator.getMediaGenerator(FLAKY_CLOUD_PROVIDER_AUTHORITY); private static final String LOCAL_ID_1 = "1"; private static final String LOCAL_ID_2 = "2"; @@ -79,6 +93,17 @@ public class PickerSyncControllerTest { private static final String CLOUD_ID_1 = "1"; private static final String CLOUD_ID_2 = "2"; private static final String CLOUD_ID_3 = "3"; + private static final String CLOUD_ID_4 = "4"; + private static final String CLOUD_ID_5 = "5"; + private static final String CLOUD_ID_6 = "6"; + private static final String CLOUD_ID_7 = "7"; + private static final String CLOUD_ID_8 = "8"; + private static final String CLOUD_ID_9 = "9"; + private static final String CLOUD_ID_10 = "10"; + private static final String CLOUD_ID_11 = "11"; + private static final String CLOUD_ID_12 = "12"; + private static final String CLOUD_ID_13 = "13"; + private static final String CLOUD_ID_14 = "14"; private static final String ALBUM_ID_1 = "1"; private static final String ALBUM_ID_2 = "2"; @@ -88,14 +113,23 @@ public class PickerSyncControllerTest { private static final Pair<String, String> CLOUD_ONLY_1 = Pair.create(null, CLOUD_ID_1); private static final Pair<String, String> CLOUD_ONLY_2 = Pair.create(null, CLOUD_ID_2); private static final Pair<String, String> CLOUD_ONLY_3 = Pair.create(null, CLOUD_ID_3); + private static final Pair<String, String> CLOUD_ONLY_4 = Pair.create(null, CLOUD_ID_4); + private static final Pair<String, String> CLOUD_ONLY_5 = Pair.create(null, CLOUD_ID_5); + private static final Pair<String, String> CLOUD_ONLY_6 = Pair.create(null, CLOUD_ID_6); + private static final Pair<String, String> CLOUD_ONLY_7 = Pair.create(null, CLOUD_ID_7); + private static final Pair<String, String> CLOUD_ONLY_8 = Pair.create(null, CLOUD_ID_8); + private static final Pair<String, String> CLOUD_ONLY_9 = Pair.create(null, CLOUD_ID_9); + private static final Pair<String, String> CLOUD_ONLY_10 = Pair.create(null, CLOUD_ID_10); + private static final Pair<String, String> CLOUD_ONLY_11 = Pair.create(null, CLOUD_ID_11); + private static final Pair<String, String> CLOUD_ONLY_12 = Pair.create(null, CLOUD_ID_12); + private static final Pair<String, String> CLOUD_ONLY_13 = Pair.create(null, CLOUD_ID_13); + private static final Pair<String, String> CLOUD_ONLY_14 = Pair.create(null, CLOUD_ID_14); private static final Pair<String, String> CLOUD_AND_LOCAL_1 = Pair.create(LOCAL_ID_1, CLOUD_ID_1); private static final String COLLECTION_1 = "1"; private static final String COLLECTION_2 = "2"; - private static final int SYNC_DELAY_MS = 1000; - private static final int DB_VERSION_1 = 1; private static final int DB_VERSION_2 = 2; private static final String DB_NAME = "test_db"; @@ -104,32 +138,36 @@ public class PickerSyncControllerTest { private TestConfigStore mConfigStore; private PickerDbFacade mFacade; private PickerSyncController mController; + private PickerSyncLockManager mLockManager; @Before public void setUp() { mLocalMediaGenerator.resetAll(); mCloudPrimaryMediaGenerator.resetAll(); mCloudSecondaryMediaGenerator.resetAll(); + mCloudFlakyMediaGenerator.resetAll(); mLocalMediaGenerator.setMediaCollectionId(COLLECTION_1); mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_1); mCloudSecondaryMediaGenerator.setMediaCollectionId(COLLECTION_1); + mCloudFlakyMediaGenerator.setMediaCollectionId(COLLECTION_1); - mContext = InstrumentationRegistry.getTargetContext(); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); // Delete db so it's recreated on next access and previous test state is cleared final File dbPath = mContext.getDatabasePath(DB_NAME); dbPath.delete(); + mLockManager = new PickerSyncLockManager(); + PickerDatabaseHelper dbHelper = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_1); - mFacade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, dbHelper); + mFacade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelper); mConfigStore = new TestConfigStore(); mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); - mConfigStore.setPickerSyncDelayMs(0); - mController = new PickerSyncController( - mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + mController = PickerSyncController.initialize( + mContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); // Set cloud provider to null to avoid trying to sync it during other tests // that might be using an IsolatedContext @@ -153,6 +191,81 @@ public class PickerSyncControllerTest { } @Test + public void testInitCloudProviderOnDeviceConfigChange() { + + TestConfigStore configStore = new TestConfigStore(); + configStore.disableCloudMediaFeature(); + + PickerSyncController controller = + PickerSyncController.initialize(mContext, mFacade, configStore, mLockManager); + assertWithMessage( + "CloudProviderInfo should have been EMPTY when CloudMediaFeature is disabled.") + .that(controller.getCurrentCloudProviderInfo()).isEqualTo(CloudProviderInfo.EMPTY); + configStore.setDefaultCloudProviderPackage(PACKAGE_NAME); + configStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); + + // Ensure the cloud provider is set to something. (The test package name here actually + // has multiple cloud providers in it, so just ensure something got set.) + assertWithMessage("Failed to set cloud provider on config change.") + .that(controller.getCurrentCloudProviderInfo().authority).isNotNull(); + + configStore.clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature(); + + // Ensure the cloud provider is correctly nulled out when the config changes again. + assertWithMessage("Failed to nullify cloud provider on config change.") + .that(controller.getCurrentCloudProviderInfo().authority).isNull(); + } + + @Test + public void testSyncIsCancelledIfCloudProviderIsChanged() throws UnableToAcquireLockException { + + PickerSyncController controller = spy(mController); + + // Ensure we return the appropriate authority until we actually enter the sync process, + // and then return a different authority than what the sync was started with to simulate + // a cloud provider changing. + doReturn(CLOUD_PRIMARY_PROVIDER_AUTHORITY, + CLOUD_SECONDARY_PROVIDER_AUTHORITY) + .when(controller) + .getCloudProviderWithTimeout(); + + // Add local only media, we expect these to be successfully sync'd from the local provider. + addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); + addMedia(mLocalMediaGenerator, LOCAL_ONLY_2); + mLocalMediaGenerator.setNextCursorExtras( + /* queryCount */ 2, + /* mediaCollectionId */ COLLECTION_1, + /* honoredSyncGeneration */ true, + /* honoredAlbumId */ false, + /* honoredPageSize */ true); + + // Add cloud media, we should try to sync these, but not actually commit them since the + // cloud provider will be changed before the transaction can be committed. + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + mCloudPrimaryMediaGenerator.setNextCursorExtras( + /* queryCount */ 2, + /* mediaCollectionId */ COLLECTION_1, + /* honoredSyncGeneration */ true, + /* honoredAlbumId */ false, + /* honoredPageSize */ true); + + controller.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + controller.syncAllMedia(); + + // The cursor should only contain the items from the local provider. (Even though we've + // added a total of 4 items to the linked providers.) + try (Cursor cr = queryMedia()) { + assertWithMessage("Cursor should only contain the items from the local provider.") + .that(cr.getCount()).isEqualTo(2); + + assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); + assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); + } + + } + + @Test public void testSyncAllMediaNoCloud() { // 1. Do nothing mController.syncAllMedia(); @@ -164,7 +277,9 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media on queryMedia() after adding two local only media.") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); @@ -175,7 +290,10 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after deleting one local-only " + + "media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); } @@ -185,7 +303,10 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after resetting media without " + + "version bump.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); } @@ -210,7 +331,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, true)) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of album medias in album albumId = " + + ALBUM_ID_1) + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); @@ -222,7 +346,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, true)) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of album medias in album albumId = " + + ALBUM_ID_1) + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); @@ -232,7 +359,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_2, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_2, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album medias in album albumId = " + + ALBUM_ID_2) + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -243,7 +373,10 @@ public class PickerSyncControllerTest { assertEmptyCursorFromAlbumMediaQuery(ALBUM_ID_1, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_2, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album medias in album albumId = " + + ALBUM_ID_2) + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -269,7 +402,9 @@ public class PickerSyncControllerTest { setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media on queryMedia() after syncing all media") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -282,7 +417,9 @@ public class PickerSyncControllerTest { // 5. Set primary cloud provider once again setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media on queryMedia() after second sync of all media.") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -299,7 +436,9 @@ public class PickerSyncControllerTest { addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after syncing all media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -310,10 +449,12 @@ public class PickerSyncControllerTest { // 3. Add another media in primary cloud provider addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); - mController.syncAllMediaFromLocalProvider(); + mController.syncAllMediaFromLocalProvider(/* cancellationSignal=*/ null); // Verify that the sync only synced local items try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(3); + assertWithMessage( + "Unexpected number of media on queryMedia() after local sync") + .that(cr.getCount()).isEqualTo(3); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); @@ -322,29 +463,6 @@ public class PickerSyncControllerTest { } @Test - public void testSyncAllMediaResetsAlbumMedia() { - // 1. Set primary cloud provider - setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); - assertEmptyCursorFromAlbumMediaQuery(ALBUM_ID_1, false); - - // 2. Add album_media - addAlbumMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1.first, CLOUD_ONLY_1.second, - ALBUM_ID_1); - addAlbumMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2.first, CLOUD_ONLY_2.second, - ALBUM_ID_1); - mController.syncAlbumMedia(ALBUM_ID_1, false); - - // 3. Assert non-empty album_media - try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(2); - } - - // 4. Sync all media and assert empty album_media - mController.syncAllMedia(); - assertEmptyCursorFromAlbumMediaQuery(ALBUM_ID_1, false); - } - - @Test public void testSyncAllAlbumMediaCloudOnly() { // 1. Add media before setting primary cloud provider addAlbumMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1.first, CLOUD_ONLY_1.second, @@ -364,7 +482,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of album medias on queryAlbumMedia() after setting cloud " + + "providers and syncing cloud album media") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -379,7 +500,10 @@ public class PickerSyncControllerTest { setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of album medias on queryAlbumMedia() after setting cloud " + + "providers and syncing cloud album media for the second time") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -414,7 +538,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after syncing first " + + "album from cloud provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -431,7 +558,10 @@ public class PickerSyncControllerTest { // 4a. Sync the first album and query local albums mController.syncAlbumMedia(ALBUM_ID_1, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after syncing first " + + "album from local provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -439,7 +569,10 @@ public class PickerSyncControllerTest { // 4b. Sync the second album mController.syncAlbumMedia(ALBUM_ID_2, true); try (Cursor cr = queryAlbumMedia(ALBUM_ID_2, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after syncing second " + + "album from local provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_2, LOCAL_PROVIDER_AUTHORITY); } @@ -447,7 +580,10 @@ public class PickerSyncControllerTest { // 5. Sync and query cloud albums mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after syncing first " + + "album from cloud provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -470,7 +606,9 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after syncing all media") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -485,7 +623,10 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after setting valid cloud version" + + " and syncing all media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -506,7 +647,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after syncing album " + + "from cloud provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -522,7 +666,10 @@ public class PickerSyncControllerTest { mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media on queryAlbumMedia() after cloud provider " + + "reset and syncing album from cloud provider") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -545,7 +692,9 @@ public class PickerSyncControllerTest { setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after syncing all media") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -555,7 +704,9 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after deleting local-only item.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @@ -565,7 +716,9 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after re-adding local-only item.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -575,7 +728,9 @@ public class PickerSyncControllerTest { mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on queryMedia() after deleting cloud+local item.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -590,64 +745,124 @@ public class PickerSyncControllerTest { @Test public void testSetCloudProvider() { //1. Get local provider assertion out of the way - assertThat(mController.getLocalProvider()).isEqualTo(LOCAL_PROVIDER_AUTHORITY); + assertWithMessage("Unexpected local provider.") + .that(mController.getLocalProvider()).isEqualTo(LOCAL_PROVIDER_AUTHORITY); // Assert that no cloud provider set on facade - assertThat(mFacade.getCloudProvider()).isNull(); + assertWithMessage("Facade cloud provider should have been null.") + .that(mFacade.getCloudProvider()).isNull(); // 2. Can set cloud provider - assertThat(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Failed to set cloud provider. ") + .that(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Unexpected cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Assert that setting cloud provider clears facade cloud provider // And after syncing, the latest provider is set on the facade - assertThat(mFacade.getCloudProvider()).isNull(); + assertWithMessage("Setting cloud provider failed to clear facade cloud provider.") + .that(mFacade.getCloudProvider()).isNull(); mController.syncAllMedia(); - assertThat(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Failed to set latest provider on the facade post sync.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // 3. Can clear cloud provider - assertThat(setCloudProviderAndSyncAllMedia(null)).isTrue(); - assertThat(mController.getCloudProvider()).isNull(); + assertWithMessage("Failed to clear cloud provider.") + .that(setCloudProviderAndSyncAllMedia(null)).isTrue(); + assertWithMessage("Cloud provider should have been null.") + .that(mController.getCloudProvider()).isNull(); // Assert that setting cloud provider clears facade cloud provider // And after syncing, the latest provider is set on the facade - assertThat(mFacade.getCloudProvider()).isNull(); + assertWithMessage("Setting cloud provider failed to clear facade cloud provider.") + .that(mFacade.getCloudProvider()).isNull(); mController.syncAllMedia(); - assertThat(mFacade.getCloudProvider()).isNull(); + assertWithMessage("Facade Cloud provider should have been null post sync.") + .that(mFacade.getCloudProvider()).isNull(); // 4. Can set cloud proivder - assertThat(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Failed to set cloud provider. ") + .that(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Unexpected cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Assert that setting cloud provider clears facade cloud provider // And after syncing, the latest provider is set on the facade - assertThat(mFacade.getCloudProvider()).isNull(); + assertWithMessage("Setting cloud provider failed to clear facade cloud provider.") + .that(mFacade.getCloudProvider()).isNull(); mController.syncAllMedia(); - assertThat(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Failed to set latest provider on the facade post sync.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Invalid cloud provider is ignored - assertThat(setCloudProviderAndSyncAllMedia(LOCAL_PROVIDER_AUTHORITY)).isFalse(); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Setting invalid cloud provider should have failed.") + .that(setCloudProviderAndSyncAllMedia(LOCAL_PROVIDER_AUTHORITY)).isFalse(); + assertWithMessage("Unexpected cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Assert that unsuccessfully setting cloud provider doesn't clear facade cloud provider // And after syncing, nothing changes - assertThat(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage( + "Unsuccessfully setting cloud provider should have failed to clear facade cloud " + + "provider.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); mController.syncAllMedia(); - assertThat(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Unexpected facade cloud provider post sync.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @Test + public void testEnableCloudQueriesAfterMPRestart() { + //1. Get local provider assertion out of the way + assertWithMessage("Unexpected local provider.") + .that(mController.getLocalProvider()).isEqualTo(LOCAL_PROVIDER_AUTHORITY); + + // Assert that no cloud provider set on facade + assertWithMessage("Facade cloud provider should have been null.") + .that(mFacade.getCloudProvider()).isNull(); + + // 2. Can set cloud provider + assertWithMessage("Failed to set cloud provider.") + .that(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Unexpected cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + // Assert that setting cloud provider clears facade cloud provider + // And after syncing, the latest provider is set on the facade + assertWithMessage("Setting cloud provider failed to clear facade cloud provider.") + .that(mFacade.getCloudProvider()).isNull(); + mController.syncAllMedia(); + assertWithMessage("Unexpected facade cloud provider post sync.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + // 3. Clear facade cloud provider to simulate MP restart. + mFacade.setCloudProvider(null); + + // 4. Assert that latest provider is set in the facade after sync even when no sync was + // required. + mController.syncAllMedia(); + assertWithMessage("Failed to set latest provider in the facade after MP restart.") + .that(mFacade.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + + + @Test public void testGetSupportedCloudProviders() { List<CloudProviderInfo> providers = mController.getAvailableCloudProviders(); - CloudProviderInfo primaryInfo = new CloudProviderInfo(CLOUD_PRIMARY_PROVIDER_AUTHORITY, - PACKAGE_NAME, - Process.myUid()); - CloudProviderInfo secondaryInfo = new CloudProviderInfo(CLOUD_SECONDARY_PROVIDER_AUTHORITY, - PACKAGE_NAME, - Process.myUid()); - - assertThat(providers).containsExactly(primaryInfo, secondaryInfo); + final CloudProviderInfo primaryInfo = + new CloudProviderInfo( + CLOUD_PRIMARY_PROVIDER_AUTHORITY, PACKAGE_NAME, Process.myUid()); + final CloudProviderInfo secondaryInfo = + new CloudProviderInfo( + CLOUD_SECONDARY_PROVIDER_AUTHORITY, PACKAGE_NAME, Process.myUid()); + final CloudProviderInfo flakyInfo = + new CloudProviderInfo( + FLAKY_CLOUD_PROVIDER_AUTHORITY, PACKAGE_NAME, Process.myUid()); + + assertWithMessage( + "Unexpected cloud provider in the list returned by getAvailableCloudProviders().") + .that(providers).containsExactly(primaryInfo, secondaryInfo, flakyInfo); } @Test @@ -656,36 +871,54 @@ public class PickerSyncControllerTest { CLOUD_PRIMARY_PROVIDER_AUTHORITY, PACKAGE_NAME, Process.myUid()); final CloudProviderInfo secondaryInfo = new CloudProviderInfo( CLOUD_SECONDARY_PROVIDER_AUTHORITY, PACKAGE_NAME, Process.myUid()); + final CloudProviderInfo flakyInfo = new CloudProviderInfo(FLAKY_CLOUD_PROVIDER_AUTHORITY, + PACKAGE_NAME, + Process.myUid()); mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); - final PickerSyncController controller = new PickerSyncController( - mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + final PickerSyncController controller = PickerSyncController.initialize( + mContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); final List<CloudProviderInfo> providers = controller.getAvailableCloudProviders(); - assertThat(providers).containsExactly(primaryInfo, secondaryInfo); + assertWithMessage( + "Unexpected cloud provider in the list returned by getAvailableCloudProviders() " + + "when using allowList.") + .that(providers).containsExactly(primaryInfo, secondaryInfo, flakyInfo); } @Test public void testNotifyPackageRemoval_NoDefaultCloudProviderPackage() { mConfigStore.clearDefaultCloudProviderPackage(); - assertThat(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Failed to set cloud provider.") + .that(mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Unexpected cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Assert passing wrong package name doesn't clear the current cloud provider mController.notifyPackageRemoval(PACKAGE_NAME + "invalid"); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage( + "Unexpected cloud provider, passing wrong package shouldn't have cleared the " + + "current cloud provider.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Assert passing the current cloud provider package name clears the current cloud provider mController.notifyPackageRemoval(PACKAGE_NAME); - assertThat(mController.getCloudProvider()).isNull(); + assertWithMessage( + "Unexpected cloud provider, passing current package should have cleared the " + + "current cloud provider.") + .that(mController.getCloudProvider()).isNull(); // Assert that the cloud provider state was not UNSET after the last cloud provider removal mConfigStore.setDefaultCloudProviderPackage(PACKAGE_NAME); - mController = - new PickerSyncController(mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + mController = PickerSyncController.initialize(mContext, mFacade, mConfigStore, + mLockManager, LOCAL_PROVIDER_AUTHORITY); - assertThat(mController.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + assertWithMessage( + "Unexpected cloud provider, cloud provider state got UNSET after the last cloud " + + "provider removal") + .that(mController.getCurrentCloudProviderInfo().packageName).isEqualTo( + PACKAGE_NAME); } // TODO(b/278687585): Add test for PickerSyncController#notifyPackageRemoval with a different @@ -694,77 +927,64 @@ public class PickerSyncControllerTest { @Test public void testSelectDefaultCloudProvider_NoDefaultAuthority() { PickerSyncController controller = createControllerWithDefaultProvider(null); - assertThat(controller.getCloudProvider()).isNull(); + assertWithMessage("Default provider was set to null.") + .that(controller.getCloudProvider()).isNull(); } @Test public void testSelectDefaultCloudProvider_defaultAuthoritySet() { PickerSyncController controller = createControllerWithDefaultProvider(PACKAGE_NAME); - assertThat(controller.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + assertWithMessage("Default provider was set to " + PACKAGE_NAME) + .that(controller.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); } @Test public void testIsProviderAuthorityEnabled() { - assertThat(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.isProviderEnabled(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isFalse(); - assertThat(mController.isProviderEnabled(CLOUD_SECONDARY_PROVIDER_AUTHORITY)).isFalse(); + assertWithMessage("Expected " + LOCAL_PROVIDER_AUTHORITY + " to be enabled.") + .that(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Expected " + CLOUD_PRIMARY_PROVIDER_AUTHORITY + " to be disabled") + .that(mController.isProviderEnabled(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isFalse(); + assertWithMessage("Expected " + CLOUD_SECONDARY_PROVIDER_AUTHORITY + " to be disabled") + .that(mController.isProviderEnabled(CLOUD_SECONDARY_PROVIDER_AUTHORITY)).isFalse(); setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); - assertThat(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.isProviderEnabled(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); - assertThat(mController.isProviderEnabled(CLOUD_SECONDARY_PROVIDER_AUTHORITY)).isFalse(); + assertWithMessage("Expected " + LOCAL_PROVIDER_AUTHORITY + " to be enabled.") + .that(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Expected " + CLOUD_PRIMARY_PROVIDER_AUTHORITY + " to be enabled.") + .that(mController.isProviderEnabled(CLOUD_PRIMARY_PROVIDER_AUTHORITY)).isTrue(); + assertWithMessage("Expected " + CLOUD_SECONDARY_PROVIDER_AUTHORITY + " to be disabled.") + .that(mController.isProviderEnabled(CLOUD_SECONDARY_PROVIDER_AUTHORITY)).isFalse(); } @Test public void testIsProviderUidEnabled() { - assertThat(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY, Process.myUid())) + assertWithMessage("Expected " + LOCAL_PROVIDER_AUTHORITY + " uid = " + Process.myUid() + + " to be enabled.") + .that(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY, Process.myUid())) .isTrue(); - assertThat(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY, 1000)).isFalse(); - } - - @Test - public void testNotifyMediaEvent() { - mConfigStore.clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature(); - mConfigStore.setPickerSyncDelayMs(SYNC_DELAY_MS); - - final PickerSyncController controller = new PickerSyncController( - mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); - - // 1. Add media and notify - addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); - controller.notifyMediaEvent(); - waitForIdle(); - assertEmptyCursorFromMediaQuery(); - - // 2. Sleep for delay - SystemClock.sleep(SYNC_DELAY_MS); - waitForIdle(); - - try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); - - assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); - } + assertWithMessage( + "Expected " + LOCAL_PROVIDER_AUTHORITY + " uid = 1000" + " to be disabled.") + .that(mController.isProviderEnabled(LOCAL_PROVIDER_AUTHORITY, 1000)).isFalse(); } @Test public void testSyncAfterDbCreate() { mConfigStore.clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature(); - mConfigStore.setPickerSyncDelayMs(0); final PickerDatabaseHelper dbHelper = new PickerDatabaseHelper( mContext, DB_NAME, DB_VERSION_1); - PickerDbFacade facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + PickerDbFacade facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelper); - PickerSyncController controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); controller.syncAllMedia(); try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after adding one local-only media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -775,20 +995,22 @@ public class PickerSyncControllerTest { final File dbPath = mContext.getDatabasePath(DB_NAME); dbPath.delete(); - facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, dbHelper); - controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelper); + controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); // Initially empty db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage("Unexpected number of media after deleting and recreating the db.") + .that(cr.getCount()).isEqualTo(0); } controller.syncAllMedia(); // Fully synced db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after fully syncing the recreated db.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -797,19 +1019,19 @@ public class PickerSyncControllerTest { @Test public void testSyncAfterDbUpgrade() { mConfigStore.clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature(); - mConfigStore.setPickerSyncDelayMs(SYNC_DELAY_MS); PickerDatabaseHelper dbHelperV1 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_1); - PickerDbFacade facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + PickerDbFacade facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV1); - PickerSyncController controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); controller.syncAllMedia(); try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after adding one local-only media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -817,20 +1039,22 @@ public class PickerSyncControllerTest { // Upgrade db version dbHelperV1.close(); PickerDatabaseHelper dbHelperV2 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_2); - facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, dbHelperV2); - controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV2); + controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); // Initially empty db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage("Unexpected number of media after upgrading the db version.") + .that(cr.getCount()).isEqualTo(0); } controller.syncAllMedia(); // Fully synced db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after fully syncing the upgraded db.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -839,19 +1063,19 @@ public class PickerSyncControllerTest { @Test public void testSyncAfterDbDowngrade() { mConfigStore.clearAllowedCloudProviderPackagesAndDisableCloudMediaFeature(); - mConfigStore.setPickerSyncDelayMs(SYNC_DELAY_MS); PickerDatabaseHelper dbHelperV2 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_2); - PickerDbFacade facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + PickerDbFacade facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV2); - PickerSyncController controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); addMedia(mLocalMediaGenerator, LOCAL_ONLY_1); controller.syncAllMedia(); try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after adding one local-only media.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -859,21 +1083,23 @@ public class PickerSyncControllerTest { // Downgrade db version dbHelperV2.close(); PickerDatabaseHelper dbHelperV1 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_1); - facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV1); - controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); // Initially empty db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage("Unexpected number of media after downgrading the db version.") + .that(cr.getCount()).isEqualTo(0); } controller.syncAllMedia(); // Fully synced db try (Cursor cr = queryMedia(facade)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media after fully syncing the downgraded db.") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, LOCAL_ID_1, LOCAL_PROVIDER_AUTHORITY); } @@ -886,7 +1112,7 @@ public class PickerSyncControllerTest { // 2. Force the next 2 syncs (including retry) to have correct extra_media_collection_id mCloudPrimaryMediaGenerator.setNextCursorExtras(2, COLLECTION_1, - /* honoredSyncGeneration */ true, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ true, /* honoredAlbumId */ false, true); // 4. Add cloud media addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); @@ -894,20 +1120,26 @@ public class PickerSyncControllerTest { // 5. Sync and verify media mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on syncing all media with correct " + + "extra_media_collection_id") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } // 6. Force the next sync (without retry) to have incorrect extra_media_collection_id mCloudPrimaryMediaGenerator.setNextCursorExtras(1, COLLECTION_2, - /* honoredSyncGeneration */ true, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ true, /* honoredAlbumId */ false, true); addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); // 7. Sync and verify media after retry succeeded mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media on syncing all media with incorrect " + + "extra_media_collection_id") + .that(cr.getCount()).isEqualTo(2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -915,7 +1147,7 @@ public class PickerSyncControllerTest { // 8. Force the next 2 syncs (including retry) to have incorrect extra_media_collection_id mCloudPrimaryMediaGenerator.setNextCursorExtras(2, COLLECTION_2, - /* honoredSyncGeneration */ true, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ true, /* honoredAlbumId */ false, true); addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_3); // 9. Sync and verify media was reset @@ -928,9 +1160,9 @@ public class PickerSyncControllerTest { // 1. Set cloud provider setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); - // 2. Force the next 2 syncs (including retry) to have correct extra_media_collection_id + // 2. Force the next 2 syncs (including retry) to have correct extra_honored_args mCloudPrimaryMediaGenerator.setNextCursorExtras(2, COLLECTION_1, - /* honoredSyncGeneration */ true, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ true, /* honoredAlbumId */ false, true); // 3. Add cloud media addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); @@ -938,20 +1170,50 @@ public class PickerSyncControllerTest { // 4. Sync and verify media mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media on syncing all media with correct " + + "extra_honored_args") + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } // 5. Force the next sync (without retry) to have incorrect extra_honored_args mCloudPrimaryMediaGenerator.setNextCursorExtras(1, COLLECTION_1, - /* honoredSyncGeneration */ false, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ false, /* honoredAlbumId */ false, true); addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); // 6. Sync and verify media after retry succeeded mController.syncAllMedia(); try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media on syncing all media with incorrect " + + "extra_honored_args") + .that(cr.getCount()).isEqualTo(2); + + assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + } + + @Test + public void testSyncAllMedia_missingOptionalHonoredArgs_displaysCloud() { + // 1. Set cloud provider + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + // 2. Add media before syncing again with the cloud provider + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + + // 3. Force next sync to not honour page size + mCloudPrimaryMediaGenerator.setNextCursorExtras(2, COLLECTION_1, + /* honoredSyncGeneration */ true, /* honoredAlbumId */ false, false); + + // 4. Sync and verify media + mController.syncAllMedia(); + try (Cursor cr = queryMedia()) { + assertWithMessage("Unexpected number of media") + .that(cr.getCount()).isEqualTo(/* expected= */ 2); assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); @@ -965,7 +1227,7 @@ public class PickerSyncControllerTest { // 2. Force the next sync to have correct extra_media_collection_id mCloudPrimaryMediaGenerator.setNextCursorExtras(1, COLLECTION_1, - /* honoredSyncGeneration */ false, /* honoredAlbumId */ true); + /* honoredSyncGeneration */ false, /* honoredAlbumId */ true, true); // 3. Add cloud album_media addAlbumMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1.first, CLOUD_ONLY_1.second, @@ -974,14 +1236,16 @@ public class PickerSyncControllerTest { // 4. Sync and verify album_media mController.syncAlbumMedia(ALBUM_ID_1, false); try (Cursor cr = queryAlbumMedia(ALBUM_ID_1, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of album media from album with albumId = " + ALBUM_ID_1) + .that(cr.getCount()).isEqualTo(1); assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); } // 5. Force the next sync to have incorrect extra_album_id mCloudPrimaryMediaGenerator.setNextCursorExtras(1, COLLECTION_1, - /* honoredSyncGeneration */ false, /* honoredAlbumId */ false); + /* honoredSyncGeneration */ false, /* honoredAlbumId */ false, true); // 6. Sync and verify album_media is empty mController.syncAlbumMedia(ALBUM_ID_1, false); @@ -991,26 +1255,27 @@ public class PickerSyncControllerTest { @Test public void testUserPrefsAfterDbUpgrade() { mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); - mConfigStore.setPickerSyncDelayMs(SYNC_DELAY_MS); PickerDatabaseHelper dbHelperV1 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_1); - PickerDbFacade facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + PickerDbFacade facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV1); - PickerSyncController controller = - new PickerSyncController(mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); controller.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); - assertThat(controller.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Unexpected cloud provider on db set up.") + .that(controller.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); // Downgrade db version dbHelperV1.close(); PickerDatabaseHelper dbHelperV2 = new PickerDatabaseHelper(mContext, DB_NAME, DB_VERSION_2); - facade = new PickerDbFacade(mContext, LOCAL_PROVIDER_AUTHORITY, + facade = new PickerDbFacade(mContext, mLockManager, LOCAL_PROVIDER_AUTHORITY, dbHelperV2); - controller = new PickerSyncController( - mContext, facade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + controller = PickerSyncController.initialize( + mContext, facade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); - assertThat(controller.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertWithMessage("Unexpected cloud provider after db version downgrade.") + .that(controller.getCloudProvider()).isEqualTo(CLOUD_PRIMARY_PROVIDER_AUTHORITY); } @Test @@ -1020,43 +1285,511 @@ public class PickerSyncControllerTest { // Test the default NOT_SET state mController = - new PickerSyncController(mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController.initialize( + mContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); - assertThat(mController.getCurrentCloudProviderInfo().packageName).isEqualTo(PACKAGE_NAME); + assertWithMessage("Unexpected cloud provider on testing the default NOT_SET state.") + .that(mController.getCurrentCloudProviderInfo().packageName).isEqualTo( + PACKAGE_NAME); // Set and test the UNSET state mController.setCloudProvider(/* authority */ null); mController = - new PickerSyncController(mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController.initialize( + mContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); - assertThat(mController.getCloudProvider()).isNull(); + assertWithMessage("Unexpected cloud provider on setting and testing the NOT_SET state.") + .that(mController.getCloudProvider()).isNull(); // Set and test the SET state mController.setCloudProvider(CLOUD_SECONDARY_PROVIDER_AUTHORITY); mController = - new PickerSyncController(mContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + PickerSyncController.initialize( + mContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); - assertThat(mController.getCloudProvider()).isEqualTo(CLOUD_SECONDARY_PROVIDER_AUTHORITY); + assertWithMessage("Unexpected cloud provider on setting and testing the SET state.") + .that(mController.getCloudProvider()).isEqualTo(CLOUD_SECONDARY_PROVIDER_AUTHORITY); } @Test public void testAvailableCloudProviders_CloudFeatureDisabled() { - assertThat(mController.getAvailableCloudProviders()).isNotEmpty(); + assertWithMessage("Empty list returned by getAvailableCloudProviders().") + .that(mController.getAvailableCloudProviders()).isNotEmpty(); mConfigStore.disableCloudMediaFeature(); - assertThat(mController.getAvailableCloudProviders()).isEmpty(); + assertWithMessage( + "Non-empty list returned by getAvailableCloudProviders() after disabling the " + + "cloud media feature.") + .that(mController.getAvailableCloudProviders()).isEmpty(); } - private static void waitForIdle() { + @Test + public void testSyncWithMultiplePages() { + + // First Page + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_5); + // Second Page + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_9); + + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + try (Cursor cr = queryMedia()) { + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 9 cloud-only media.") + .that(cr.getCount()).isEqualTo(9); + assertCursor(cr, CLOUD_ID_9, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_8, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_7, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_6, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_5, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_4, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_3, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + } + + @Test + public void testSyncDeletedItemsWithMultiplePages() { + + // First Page + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_5); + // Second Page + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_9); + + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + try (Cursor cr = queryMedia()) { + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 9 cloud-only media.") + .that(cr.getCount()).isEqualTo(9); + assertCursor(cr, CLOUD_ID_9, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_8, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_7, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_6, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_5, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_4, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_3, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_2, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_3); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_4); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_5); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_6); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_7); + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_8); + + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + assertWithMessage( + "Unexpected number of media on queryMedia() after deleting 8 out the 9 " + + "cloud-only media.") + .that(cr.getCount()).isEqualTo(1); + assertCursor(cr, CLOUD_ID_9, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + + } + + @Test + public void testResumableIncrementalSyncOperation() { + // First Page + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_5); + + // Complete a full sync since it hasn't synced before. + setCloudProviderAndSyncAllMedia(FLAKY_CLOUD_PROVIDER_AUTHORITY); + mController.syncAllMedia(); + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Should only have the first page since the sync is flaky + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 5 cloud-only media.") + .that(cr.getCount()).isEqualTo(5); + assertCursor(cr, CLOUD_ID_5, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_4, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_3, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_2, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, FLAKY_CLOUD_PROVIDER_AUTHORITY); + } + + // Add some more data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_9); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_10); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_11); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_12); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_13); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_14); + + // FlakyCloudMediaProvider will throw errors on 2 out of 3 requests, so we need to sync + // a few times to ensure we resume the mid-sync failure. + mController.syncAllMedia(); + mController.syncAllMedia(); + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Should have all pages now + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 9 cloud-only media, " + + "in addition to previously added 5 cloud-only media.") + .that(cr.getCount()).isEqualTo(14); + assertCursor(cr, CLOUD_ID_14, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_13, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_12, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_11, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_10, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_9, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_8, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_7, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_6, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_5, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_4, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_3, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_2, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, FLAKY_CLOUD_PROVIDER_AUTHORITY); + } + } + + @Test + public void testResumableFullSyncOperation() { + // First Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_5); + // Second Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_9); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_10); + // Third Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_11); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_12); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_13); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_14); + + mController.setCloudProvider(FLAKY_CLOUD_PROVIDER_AUTHORITY); + try (Cursor cr = queryMedia()) { + // Db should be empty since we haven't synced yet. + assertWithMessage( + "Unexpected number of media on queryMedia() before sync.") + .that(cr.getCount()).isEqualTo(0); + } + + // FlakyCloudMediaProvider will throw errors on 2 out of 3 requests, if we sync once, it + // should not be able to complete the sync. + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Assert that the sync is not complete. + assertWithMessage( + "Unexpected number of media on queryMedia().") + .that(cr.getCount()).isLessThan(14); + } + + // Resume sync and complete it. It will take a few sync calls to complete the sync. + mController.syncAllMedia(); + mController.syncAllMedia(); + mController.syncAllMedia(); + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Should have all pages now + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 14 cloud-only media.") + .that(cr.getCount()).isEqualTo(14); + assertCursor(cr, CLOUD_ID_14, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_13, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_12, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_11, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_10, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_9, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_8, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_7, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_6, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_5, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_4, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_3, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_2, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_1, FLAKY_CLOUD_PROVIDER_AUTHORITY); + } + } + + @Test + public void testFullSyncWithCollectionIdChange() { + mController.setCloudProvider(FLAKY_CLOUD_PROVIDER_AUTHORITY); + mCloudFlakyMediaGenerator.setMediaCollectionId(COLLECTION_1); + + // First Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_5); + // Second Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_9); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_10); + // Third Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_11); + + // FlakyCloudMediaProvider will throw errors on 2 out of 3 requests, if we sync once, it + // should not be able to complete the sync. + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Assert that the sync is not complete. + assertWithMessage( + "Unexpected number of media on queryMedia().") + .that(cr.getCount()).isLessThan(11); + } + + // Reset data and change collection id. + mCloudFlakyMediaGenerator.resetAll(); + mCloudFlakyMediaGenerator.setMediaCollectionId(COLLECTION_2); + + // First Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_12); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_13); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_14); + + // FlakyCloudMediaProvider will throw errors on 2 out of 3 requests. It will take a few + // tries to complete the sync. + mController.syncAllMedia(); + mController.syncAllMedia(); + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Db should be empty since we haven't synced yet. + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 3 cloud-only media.") + .that(cr.getCount()).isEqualTo(3); + assertCursor(cr, CLOUD_ID_14, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_13, FLAKY_CLOUD_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_12, FLAKY_CLOUD_PROVIDER_AUTHORITY); + } + } + + @Test + public void testFullSyncWithCloudProviderChange() { + mController.setCloudProvider(FLAKY_CLOUD_PROVIDER_AUTHORITY); + + // First Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_5); + // Second Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_6); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_7); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_8); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_9); + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_10); + // Third Page of data + addMedia(mCloudFlakyMediaGenerator, CLOUD_ONLY_11); + + // FlakyCloudMediaProvider will throw errors on 2 out of 3 requests, if we sync once, it + // should not be able to complete the sync. + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Assert that the sync is not complete. + assertWithMessage( + "Unexpected number of media on queryMedia().") + .that(cr.getCount()).isLessThan(11); + } + + mController.setCloudProvider(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + // First Page of data + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_12); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_13); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_14); + + mController.syncAllMedia(); + + try (Cursor cr = queryMedia()) { + // Db should be empty since we haven't synced yet. + assertWithMessage( + "Unexpected number of media on queryMedia() after adding 3 cloud-only media.") + .that(cr.getCount()).isEqualTo(3); + assertCursor(cr, CLOUD_ID_14, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_13, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + assertCursor(cr, CLOUD_ID_12, CLOUD_PRIMARY_PROVIDER_AUTHORITY); + } + } + + @Test + public void testContentAddNotifications() throws Exception { + NotificationContentObserver observer = new NotificationContentObserver(null); + observer.register(mContext.getContentResolver()); + + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_1); + final CountDownLatch latch = new CountDownLatch(1); - BackgroundThread.getExecutor().execute(latch::countDown); + final NotificationContentObserver.ContentObserverCallback callback = + spy(new TestableContentObserverCallback(latch)); + observer.registerKeysToObserverCallback(Arrays.asList(MEDIA), callback); + + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_2); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_3); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_4); + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_5); + mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_2); + + mController.syncAllMedia(); + + // Wait until the callback has received the notification. + latch.await(5, TimeUnit.SECONDS); + + try (Cursor cr = queryMedia()) { + cr.moveToFirst(); + verify(callback) + .onNotificationReceived( + cr.getString(cr.getColumnIndex(MediaColumns.DATE_TAKEN_MILLIS)), null); + } finally { + observer.unregister(mContext.getContentResolver()); + } + } + + @Test + public void testContentDeleteNotifications() throws Exception { + NotificationContentObserver observer = new NotificationContentObserver(null); + observer.register(mContext.getContentResolver()); + + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + + CountDownLatch latch = new CountDownLatch(1); + NotificationContentObserver.ContentObserverCallback callback = + spy(new TestableContentObserverCallback(latch)); + observer.registerKeysToObserverCallback(Arrays.asList(MEDIA), callback); + + addMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_1); + mController.syncAllMedia(); + latch.await(2, TimeUnit.SECONDS); + verify(callback).onNotificationReceived(any(), any()); + + latch = new CountDownLatch(1); + callback = spy(new TestableContentObserverCallback(latch)); + observer.registerKeysToObserverCallback(Arrays.asList(MEDIA), callback); + + deleteMedia(mCloudPrimaryMediaGenerator, CLOUD_ONLY_1); + mController.syncAllMedia(); + latch.await(2, TimeUnit.SECONDS); + verify(callback).onNotificationReceived(any(), any()); + + observer.unregister(mContext.getContentResolver()); + } + + @Test + public void testCollectionIdChangeResetsUi() throws InterruptedException { + final ContentResolver contentResolver = mContext.getContentResolver(); + final TestContentObserver refreshUiNotificationObserver = new TestContentObserver(null); try { - latch.await(30, TimeUnit.SECONDS); - } catch (InterruptedException e) { - throw new IllegalStateException(e); + setCloudProviderAndSyncAllMedia(CLOUD_PRIMARY_PROVIDER_AUTHORITY); + mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_1); + + // Simulate a UI session begins listening. + contentResolver.registerContentObserver(REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, + /* notifyForDescendants */ false, refreshUiNotificationObserver); + + mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_2); + + mController.syncAllMedia(); + + assertWithMessage("Refresh ui notification should have been received.") + .that(refreshUiNotificationObserver.mNotificationReceived).isTrue(); + } finally { + contentResolver.unregisterContentObserver(refreshUiNotificationObserver); } + } + + @Test + public void testRefreshUiNotifications() throws InterruptedException { + final ContentResolver contentResolver = mContext.getContentResolver(); + final TestContentObserver refreshUiNotificationObserver = new TestContentObserver(null); + try { + contentResolver.registerContentObserver(REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, + /* notifyForDescendants */ false, refreshUiNotificationObserver); + assertWithMessage("Refresh ui notification should have not been received.") + .that(refreshUiNotificationObserver.mNotificationReceived).isFalse(); + + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(PACKAGE_NAME); + mConfigStore.setDefaultCloudProviderPackage(PACKAGE_NAME); + + // The cloud provider is changed on PickerSyncController construction + mController = PickerSyncController + .initialize(mContext, mFacade, mConfigStore, mLockManager); + TimeUnit.MILLISECONDS.sleep(100); + assertWithMessage( + "Failed to receive refresh ui notification on change in cloud provider.") + .that(refreshUiNotificationObserver.mNotificationReceived).isTrue(); + + refreshUiNotificationObserver.mNotificationReceived = false; + + // The SET_CLOUD_PROVIDER is called using a different cloud provider from before + mController.setCloudProvider(CLOUD_SECONDARY_PROVIDER_AUTHORITY); + TimeUnit.MILLISECONDS.sleep(100); + assertWithMessage( + "Failed to receive refresh ui notification on change in cloud provider.") + .that(refreshUiNotificationObserver.mNotificationReceived).isTrue(); + + refreshUiNotificationObserver.mNotificationReceived = false; + + // The cloud provider remains unchanged on PickerSyncController construction + mController = PickerSyncController + .initialize(mContext, mFacade, mConfigStore, mLockManager); + TimeUnit.MILLISECONDS.sleep(100); + assertWithMessage( + "Refresh ui notification should have not been received when cloud provider " + + "remains unchanged.") + .that(refreshUiNotificationObserver.mNotificationReceived).isFalse(); + + // The SET_CLOUD_PROVIDER is called using the same cloud provider as before + mController.setCloudProvider(CLOUD_SECONDARY_PROVIDER_AUTHORITY); + TimeUnit.MILLISECONDS.sleep(100); + assertWithMessage( + "Refresh ui notification should have not been received when setCloudProvider " + + "is called using the same cloud provider as before.") + .that(refreshUiNotificationObserver.mNotificationReceived).isFalse(); + } finally { + contentResolver.unregisterContentObserver(refreshUiNotificationObserver); + } } private static void addMedia(MediaGenerator generator, Pair<String, String> media) { @@ -1098,13 +1831,15 @@ public class PickerSyncControllerTest { private void assertEmptyCursorFromMediaQuery() { try (Cursor cr = queryMedia()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage("Cursor should have been empty.") + .that(cr.getCount()).isEqualTo(0); } } private void assertEmptyCursorFromAlbumMediaQuery(String albumId, boolean isLocal) { try (Cursor cr = queryAlbumMedia(albumId, isLocal)) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage("Cursor from queryAlbumMedia should have been empty.") + .that(cr.getCount()).isEqualTo(0); } } @@ -1127,17 +1862,31 @@ public class PickerSyncControllerTest { } else { mConfigStore.clearDefaultCloudProviderPackage(); } - mConfigStore.setPickerSyncDelayMs(SYNC_DELAY_MS); - return new PickerSyncController( - mockContext, mFacade, mConfigStore, LOCAL_PROVIDER_AUTHORITY); + return PickerSyncController.initialize( + mockContext, mFacade, mConfigStore, mLockManager, LOCAL_PROVIDER_AUTHORITY); } private static void assertCursor(Cursor cursor, String id, String expectedAuthority) { cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.ID))) + assertWithMessage("Unexpected value of MediaColumns.ID in the cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.ID))) .isEqualTo(id); - assertThat(cursor.getString(cursor.getColumnIndex( MediaColumns.AUTHORITY))) + assertWithMessage("Unexpected value of MediaColumns.AUTHORITY in the cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.AUTHORITY))) .isEqualTo(expectedAuthority); } + + private static class TestContentObserver extends ContentObserver { + boolean mNotificationReceived; + + TestContentObserver(Handler handler) { + super(handler); + } + + @Override + public void onChange(boolean selfChange) { + mNotificationReceived = true; + } + } } diff --git a/tests/src/com/android/providers/media/photopicker/SafetyProtectionUtilsTest.java b/tests/src/com/android/providers/media/photopicker/SafetyProtectionUtilsTest.java index f176325c5..745acc0dc 100644 --- a/tests/src/com/android/providers/media/photopicker/SafetyProtectionUtilsTest.java +++ b/tests/src/com/android/providers/media/photopicker/SafetyProtectionUtilsTest.java @@ -34,7 +34,6 @@ import com.android.providers.media.photopicker.util.SafetyProtectionUtils; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; @@ -74,7 +73,6 @@ public class SafetyProtectionUtilsTest { }); } - @Ignore("Enable once b/269874157 is fixed") @Test public void testWhetherShouldUseSafetyProtectionResourcesWhenTOrAboveAndFeatureFlagOn() { assumeTrue(SdkLevel.isAtLeastT()); diff --git a/tests/src/com/android/providers/media/photopicker/TEST_MAPPING b/tests/src/com/android/providers/media/photopicker/TEST_MAPPING new file mode 100644 index 000000000..7bcae5de0 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/TEST_MAPPING @@ -0,0 +1,42 @@ +{ + "mainline-presubmit": [ + { + "name": "CtsPhotoPickerTest[com.google.android.mediaprovider.apex]", + "options": [ + { + "exclude-annotation": "androidx.test.filters.LargeTest" + } + ] + }, + { + "name": "MediaProviderTests[com.google.android.mediaprovider.apex]", + "options": [ + { + // For changes in Photopicker tests we want to run all of the photopicker + // tests in the given package regardless of @RunOnlyOnPostsubmit annotation + "include-filter": "com.android.providers.media.photopicker" + } + ] + } + ], + "presubmit": [ + { + "name": "CtsPhotoPickerTest", + "options": [ + { + "exclude-annotation": "androidx.test.filters.LargeTest" + } + ] + }, + { + "name": "MediaProviderTests", + "options": [ + { + // For changes in Photopicker tests we want to run all of the photopicker + // tests in the given package regardless of @RunOnlyOnPostsubmit annotation + "include-filter": "com.android.providers.media.photopicker" + } + ] + } + ] +} diff --git a/tests/src/com/android/providers/media/photopicker/TestableContentObserverCallback.java b/tests/src/com/android/providers/media/photopicker/TestableContentObserverCallback.java new file mode 100644 index 000000000..cf24aef76 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/TestableContentObserverCallback.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 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 androidx.annotation.Nullable; + +import java.util.concurrent.CountDownLatch; + +public class TestableContentObserverCallback + implements NotificationContentObserver.ContentObserverCallback { + + @Nullable private CountDownLatch mLatch; + + public TestableContentObserverCallback() {} + + public TestableContentObserverCallback(CountDownLatch latch) { + this.mLatch = latch; + } + + @Override + public void onNotificationReceived(String dateTakenMs, String albumId) { + // do nothing + if (mLatch != null) { + mLatch.countDown(); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/data/ExternalDbFacadeTest.java b/tests/src/com/android/providers/media/photopicker/data/ExternalDbFacadeTest.java index 0a82af838..c0328441c 100644 --- a/tests/src/com/android/providers/media/photopicker/data/ExternalDbFacadeTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/ExternalDbFacadeTest.java @@ -22,16 +22,20 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS; import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID; import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE; +import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN; import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION; import static android.provider.CloudMediaProviderContract.MediaCollectionInfo; import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_GIF; import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE; +import static android.provider.MediaStore.MediaColumns.DATE_TAKEN; import static com.android.providers.media.photopicker.data.ExternalDbFacade.COLUMN_OLD_ID; import static com.android.providers.media.photopicker.data.ExternalDbFacade.TABLE_DELETED_MEDIA; import static com.android.providers.media.photopicker.data.ExternalDbFacade.TABLE_FILES; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -79,10 +83,12 @@ public class ExternalDbFacadeTest { private static final long DATE_TAKEN_MS3 = 1624886050568L; private static final long DATE_TAKEN_MS4 = 1624886050569L; private static final long DATE_TAKEN_MS5 = 1624886050570L; + private static final long DATE_MODIFIED_MS1 = 1625000011L; + private static final long DATE_MODIFIED_MS2 = 1625000012L; + private static final long DATE_MODIFIED_MS3 = 1625000013L; private static final long GENERATION_MODIFIED1 = 1; private static final long GENERATION_MODIFIED2 = 2; private static final long GENERATION_MODIFIED3 = 3; - private static final long GENERATION_MODIFIED4 = 4; private static final long GENERATION_MODIFIED5 = 5; private static final long SIZE = 8000; private static final long HEIGHT = 500; @@ -109,50 +115,82 @@ public class ExternalDbFacadeTest { ExternalDbFacade facade = new ExternalDbFacade(sIsolatedContext, helper, mock(VolumeCache.class)); - assertThat(facade.addDeletedMedia(ID1)).isTrue(); - assertThat(facade.addDeletedMedia(ID2)).isTrue(); + if (!facade.addDeletedMedia(ID1)) { + assertWithMessage("Adding item with ID %d failed", + ID1).fail(); + } + if (!facade.addDeletedMedia(ID2)) { + assertWithMessage("Adding item with ID %d failed", + ID2).fail(); + } try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 0)) { - assertThat(cursor.getCount()).isEqualTo(2); - + assertWithMessage( + "Number of rows in the deleted_media table with generation greater than 0" + + " was") + .that(cursor.getCount()).isEqualTo(2); ArrayList<Long> ids = new ArrayList<>(); while (cursor.moveToNext()) { ids.add(cursor.getLong(0)); } - - assertThat(ids).contains(ID1); - assertThat(ids).contains(ID2); + assertWithMessage("The list of ids from delete_media table") + .that(ids).contains(ID1); + assertWithMessage("The list of ids from delete_media table") + .that(ids).contains(ID2); } // Filter by generation should only return ID2 try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 1)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage( + "Number of rows in the deleted_media table with generation greater than 1" + + " is") + .that(cursor.getCount()).isEqualTo(1); cursor.moveToFirst(); - assertThat(cursor.getLong(0)).isEqualTo(ID2); + assertWithMessage("ID fro row having generation greater than 1") + .that(cursor.getLong(0)).isEqualTo(ID2); } // Adding ids again should succeed but bump generation_modified of ID1 and ID2 - assertThat(facade.addDeletedMedia(ID1)).isTrue(); - assertThat(facade.addDeletedMedia(ID2)).isTrue(); + if (!facade.addDeletedMedia(ID1)) { + assertWithMessage("Adding item with ID %d failed", + ID1).fail(); + } + if (!facade.addDeletedMedia(ID2)) { + assertWithMessage("Adding item with ID %d failed", + ID2).fail(); + } // Filter by generation again, now returns both ids since their generation_modified was // bumped try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 1)) { - assertThat(cursor.getCount()).isEqualTo(2); + assertWithMessage( + "Number of rows in the deleted_media table with generation greater than 1" + + " is") + .that(cursor.getCount()).isEqualTo(2); } // Remove ID2 should succeed - assertThat(facade.removeDeletedMedia(ID2)).isTrue(); + if (!facade.removeDeletedMedia(ID2)) { + assertWithMessage("Removing item with ID %d failed", ID2).fail(); + } // Remove ID2 again should fail - assertThat(facade.removeDeletedMedia(ID2)).isFalse(); + if (facade.removeDeletedMedia(ID2)) { + assertWithMessage("Removing item with ID %d should have failed", ID2).fail(); + } // Verify only ID1 left try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 0)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage( + "Number of rows in the deleted_media table with generation greater than 0" + + " is") + .that(cursor.getCount()).isEqualTo(1); cursor.moveToFirst(); - assertThat(cursor.getLong(0)).isEqualTo(ID1); + assertWithMessage( + "ID of the item left in the deleted_media table after deleting row with " + + "id=ID2 is") + .that(cursor.getLong(0)).isEqualTo(ID1); } } } @@ -163,18 +201,33 @@ public class ExternalDbFacadeTest { ExternalDbFacade facade = new ExternalDbFacade(sIsolatedContext, helper, mock(VolumeCache.class)); - assertThat(facade.onFileInserted(FileColumns.MEDIA_TYPE_VIDEO, /* isPending */ false)) - .isTrue(); - assertThat(facade.onFileInserted(FileColumns.MEDIA_TYPE_IMAGE, /* isPending */ false)) - .isTrue(); + if (!facade.onFileInserted(FileColumns.MEDIA_TYPE_VIDEO, /* isPending */ false)) { + assertWithMessage( + "Expected to return true but returned false on Insert of " + + "MEDIA_TYPE_VIDEO").fail(); + } + if (!facade.onFileInserted(FileColumns.MEDIA_TYPE_IMAGE, /* isPending */ false)) { + assertWithMessage( + "Expected to return true but returned false on Insert of " + + "MEDIA_TYPE_IMAGE").fail(); + } assertDeletedMediaEmpty(facade); - assertThat(facade.onFileInserted(FileColumns.MEDIA_TYPE_AUDIO, /* isPending */ false)) - .isFalse(); - assertThat(facade.onFileInserted(FileColumns.MEDIA_TYPE_NONE, /* isPending */ false)) - .isFalse(); - assertThat(facade.onFileInserted(FileColumns.MEDIA_TYPE_IMAGE, /* isPending */ true)) - .isFalse(); + if (facade.onFileInserted(FileColumns.MEDIA_TYPE_AUDIO, /* isPending */ false)) { + assertWithMessage( + "Expected to return false but returned true on Insert of " + + "MEDIA_TYPE_AUDIO").fail(); + } + if (facade.onFileInserted(FileColumns.MEDIA_TYPE_NONE, /* isPending */ false)) { + assertWithMessage( + "Expected to return false but returned true on Insert of " + + "MEDIA_TYPE_NONE").fail(); + } + if (facade.onFileInserted(FileColumns.MEDIA_TYPE_IMAGE, /* isPending */ true)) { + assertWithMessage( + "Expected to return false but returned true on Insert of " + + " MEDIA_TYPE_IMAGE with isPending true").fail(); + } assertDeletedMediaEmpty(facade); } } @@ -186,53 +239,73 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Non-media -> non-media: no-op - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + if (facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return false but returned true on Update from " + + "MEDIA_TYPE_NONE to MEDIA_TYPE_NONE").fail(); + } assertDeletedMediaEmpty(facade); // Media -> non-media: added to deleted_media - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_NONE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_NONE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on Update from " + + "MEDIA_TYPE_IMAGE to MEDIA_TYPE_NONE").fail(); + } assertDeletedMedia(facade, ID1); // Non-media -> non-media: no-op - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + if (facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return false but returned true on Update from " + + "MEDIA_TYPE_NONE to MEDIA_TYPE_NONE").fail(); + } assertDeletedMedia(facade, ID1); // Non-media -> media: remove from deleted_media - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on Update from " + + "MEDIA_TYPE_NONE to MEDIA_TYPE_IMAGE").fail(); + } assertDeletedMediaEmpty(facade); - // Non-media -> media: no-op - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + // Non-media -> Non-media: no-op + if (facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_NONE, FileColumns.MEDIA_TYPE_NONE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return false but returned true on Update from " + + "MEDIA_TYPE_NONE to MEDIA_TYPE_NONE").fail(); + } assertDeletedMediaEmpty(facade); } } @@ -244,33 +317,47 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was trashed but is now neither trashed nor pending - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ true, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ true, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was trashed but the newMedia is neither trashed nor pending.") + .fail(); + } assertDeletedMediaEmpty(facade); // Was not trashed but is now trashed - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ true, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ true, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was not trashed but the newMedia is trashed.").fail(); + } assertDeletedMedia(facade, ID1); // Was trashed but is now neither trashed nor pending - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ true, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ true, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was trashed but the newMedia is neither trashed nor pending.") + .fail(); + } assertDeletedMediaEmpty(facade); } } @@ -282,33 +369,47 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was pending but is now neither trashed nor pending - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ true, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ true, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was pending but the newMedia is neither trashed nor pending.") + .fail(); + } assertDeletedMediaEmpty(facade); // Was not pending but is now pending - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ true, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ true, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was not pending but the newMedia is pending.").fail(); + } assertDeletedMedia(facade, ID1); // Was pending but is now neither trashed nor pending - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ true, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ true, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update, when the oldMedia " + + "was pending but the newMedia is neither trashed nor pending.") + .fail(); + } assertDeletedMediaEmpty(facade); } } @@ -320,22 +421,32 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was favorite but is now not favorited - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ true, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ true, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update with visible " + + "favorite, when the oldMedia " + + "was favorite but the newMedia is not favorite.").fail(); + } // Was not favorite but is now favorited - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ false, /* newIsFavorite */ true, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + if (!facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ false, /* newIsFavorite */ true, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update with visible " + + "favorite, when the oldMedia " + + "was not favorite but the newMedia is favorite.").fail(); + } } } @@ -346,22 +457,32 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was favorite but is now not favorited - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ true, /* newIsTrashed */ true, - /* oldIsPending */ false, /* newIsPending */ false, - /* oldIsFavorite */ true, /* newIsFavorite */ false, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + if (facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ true, /* newIsTrashed */ true, + /* oldIsPending */ false, /* newIsPending */ false, + /* oldIsFavorite */ true, /* newIsFavorite */ false, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update with hidden " + + "favorite, when the oldMedia was favorite but the newMedia is " + + "not favorite.").fail(); + } // Was not favorite but is now favorited - assertThat(facade.onFileUpdated(ID1, - FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, - /* oldIsTrashed */ false, /* newIsTrashed */ false, - /* oldIsPending */ true, /* newIsPending */ true, - /* oldIsFavorite */ false, /* newIsFavorite */ true, - /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + if (facade.onFileUpdated(ID1, + FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, + /* oldIsTrashed */ false, /* newIsTrashed */ false, + /* oldIsPending */ true, /* newIsPending */ true, + /* oldIsFavorite */ false, /* newIsFavorite */ true, + /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return false but returned true on update with hidden " + + "favorite, when the oldMedia was not favorite but the newMedia " + + "is favorite.").fail(); + } } } @@ -372,22 +493,32 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was _SPECIAL_FORMAT_NONE but is now _SPECIAL_FORMAT_GIF - assertThat(facade.onFileUpdated(ID1, + if (!facade.onFileUpdated(ID1, FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, /* oldIsTrashed */ false, /* newIsTrashed */ false, /* oldIsPending */ false, /* newIsPending */ false, /* oldIsFavorite */ false, /* newIsFavorite */ false, /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_GIF)).isTrue(); + /* newSpecialFormat */ _SPECIAL_FORMAT_GIF)) { + assertWithMessage( + "Expected to return true but returned false on update with visible " + + "special format, when the oldSpecialFormat was NONE but the " + + "newSpecialFormat is GIF.").fail(); + } // Was _SPECIAL_FORMAT_GIF but is now _SPECIAL_FORMAT_NONE - assertThat(facade.onFileUpdated(ID1, + if (!facade.onFileUpdated(ID1, FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, /* oldIsTrashed */ false, /* newIsTrashed */ false, /* oldIsPending */ false, /* newIsPending */ false, /* oldIsFavorite */ false, /* newIsFavorite */ false, /* oldSpecialFormat */ _SPECIAL_FORMAT_GIF, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isTrue(); + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return true but returned false on update with visible " + + "special format, when the oldSpecialFormat was GIF but the " + + "newSpecialFormat is NONE.").fail(); + } } } @@ -398,22 +529,32 @@ public class ExternalDbFacadeTest { mock(VolumeCache.class)); // Was _SPECIAL_FORMAT_NONE but is now _SPECIAL_FORMAT_GIF - assertThat(facade.onFileUpdated(ID1, + if (facade.onFileUpdated(ID1, FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, /* oldIsTrashed */ true, /* newIsTrashed */ true, /* oldIsPending */ false, /* newIsPending */ false, /* oldIsFavorite */ false, /* newIsFavorite */ false, /* oldSpecialFormat */ _SPECIAL_FORMAT_NONE, - /* newSpecialFormat */ _SPECIAL_FORMAT_GIF)).isFalse(); + /* newSpecialFormat */ _SPECIAL_FORMAT_GIF)) { + assertWithMessage( + "Expected to return false but returned true on update with hidden special" + + " format, when the oldSpecialFormat was NONE but the " + + "newSpecialFormat is GIF.").fail(); + } - // Was _SPECIAL_FORMAT_NONE but is now _SPECIAL_FORMAT_GIF - assertThat(facade.onFileUpdated(ID1, + // Was _SPECIAL_FORMAT_GIF but is now _SPECIAL_FORMAT_NONE + if (facade.onFileUpdated(ID1, FileColumns.MEDIA_TYPE_IMAGE, FileColumns.MEDIA_TYPE_IMAGE, /* oldIsTrashed */ false, /* newIsTrashed */ false, /* oldIsPending */ true, /* newIsPending */ true, /* oldIsFavorite */ false, /* newIsFavorite */ false, /* oldSpecialFormat */ _SPECIAL_FORMAT_GIF, - /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)).isFalse(); + /* newSpecialFormat */ _SPECIAL_FORMAT_NONE)) { + assertWithMessage( + "Expected to return false but returned true on update with hidden special" + + " format, when the oldSpecialFormat was GIF but the " + + "newSpecialFormat is NONE.").fail(); + } } } @@ -423,13 +564,25 @@ public class ExternalDbFacadeTest { ExternalDbFacade facade = new ExternalDbFacade(sIsolatedContext, helper, mock(VolumeCache.class)); - assertThat(facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_NONE)).isFalse(); + if (facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_NONE)) { + assertWithMessage( + "Expected to return false when the mediaType is NONE, but returned true " + + "on delete.").fail(); + } assertDeletedMediaEmpty(facade); - assertThat(facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_IMAGE)).isTrue(); + if (!facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_IMAGE)) { + assertWithMessage( + "Expected to return true when the mediaType is IMAGE, but returned false " + + "on delete.").fail(); + } assertDeletedMedia(facade, ID1); - assertThat(facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_NONE)).isFalse(); + if (facade.onFileDeleted(ID1, FileColumns.MEDIA_TYPE_NONE)) { + assertWithMessage( + "Expected to return false when the mediaType is NONE, but returned true " + + "on delete.").fail(); + } assertDeletedMedia(facade, ID1); } } @@ -452,7 +605,9 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(2); + assertWithMessage("Number of rows on querying TABLE_FILES for all media is") + .that(cursor.getCount()) + .isEqualTo(2); assertCursorExtras(cursor); cursor.moveToFirst(); @@ -463,9 +618,17 @@ public class ExternalDbFacadeTest { } try (Cursor cursor = facade.queryMedia(GENERATION_MODIFIED1, - /* albumId */ null, /* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(1); - assertCursorExtras(cursor, EXTRA_SYNC_GENERATION); + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 10, + /*pageToken */ null)) { + assertWithMessage( + "Number of rows on querying TABLE_FILES for (generation: " + + "GENERATION_MODIFIED1, albumId: null, mimeType: null, pageSize:" + + " 10) is") + .that(cursor.getCount()) + .isEqualTo(1); + //PAGE_TOKEN will also be set since pageSize is not -1. + assertCursorExtras(cursor, EXTRA_SYNC_GENERATION, EXTRA_PAGE_SIZE, + EXTRA_PAGE_TOKEN); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID2, DATE_TAKEN_MS1); @@ -489,7 +652,10 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cvTrashed)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(0); + assertWithMessage( + "Number of rows on querying TABLES_FILES with cvPending and cvTrashed " + + "inserted is") + .that(cursor.getCount()).isEqualTo(0); } } } @@ -504,7 +670,7 @@ public class ExternalDbFacadeTest { // Intentionally associate <dateModifiedSeconds2 with generation_modifed1> // and <dateModifiedSeconds1 with generation_modifed2> below. // This allows us verify that the sort order from queryMediaGeneration - // is based on date_taken and not generation_modified. + // is based on date_taken and _id and not generation_modified. ContentValues cv = getContentValues(DATE_TAKEN_MS2, GENERATION_MODIFIED1); cv.remove(MediaColumns.DATE_TAKEN); cv.put(MediaColumns.DATE_MODIFIED, dateModifiedSeconds2); @@ -515,18 +681,29 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(2); + assertWithMessage( + "Number of rows on querying TABLES_FILES with modified date for all media" + + " is") + .that(cursor.getCount()) + .isEqualTo(2); cursor.moveToFirst(); - assertMediaColumns(facade, cursor, ID1, dateModifiedSeconds2 * 1000); + assertMediaColumns(facade, cursor, ID2, dateModifiedSeconds2 * 1000); cursor.moveToNext(); - assertMediaColumns(facade, cursor, ID2, dateModifiedSeconds1 * 1000); + assertMediaColumns(facade, cursor, ID1, dateModifiedSeconds1 * 1000); } try (Cursor cursor = facade.queryMedia(GENERATION_MODIFIED1, - /* albumId */ null, /* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(1); + /* albumId */ null, /* mimeType */ null, /* pageSize*/ -1, + /*pageToken */ null)) { + assertWithMessage( + "Number of rows on querying TABLE_FILES with modified date for " + + "(generation: " + + "GENERATION_MODIFIED1, albumId: null, mimeType: null, pageSize:" + + " -1) is") + .that(cursor.getCount()) + .isEqualTo(1); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID2, dateModifiedSeconds1 * 1000); @@ -545,20 +722,30 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Number of rows on querying TABLES_FILES for all media is") + .that(cursor.getCount()) + .isEqualTo(1); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); } try (Cursor cursor = facade.queryMedia(/* generation */ 0, - /* albumId */ null, VIDEO_MIME_TYPES_QUERY)) { - assertThat(cursor.getCount()).isEqualTo(0); + /* albumId */ null, VIDEO_MIME_TYPES_QUERY, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with mime type VIDEO is") + .that(cursor.getCount()) + .isEqualTo(0); } try (Cursor cursor = facade.queryMedia(/* generation */ 0, - /* albumId */ null, IMAGE_MIME_TYPES_QUERY)) { - assertThat(cursor.getCount()).isEqualTo(1); + /* albumId */ null, IMAGE_MIME_TYPES_QUERY, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with mime type IMAGE is") + .that(cursor.getCount()) + .isEqualTo(1); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); @@ -575,21 +762,33 @@ public class ExternalDbFacadeTest { initMediaInAllAlbums(helper); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(3); + assertWithMessage("Number of rows on querying TABLES_FILES for all media is") + .that(cursor.getCount()) + .isEqualTo(3); } try (Cursor cursor = facade.queryMedia(/* generation */ -1, - ALBUM_ID_CAMERA, /* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(1); - assertCursorExtras(cursor, EXTRA_ALBUM_ID); + ALBUM_ID_CAMERA, /* mimeType */ null, /* pageSize*/ 20, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with ALBUM_ID_CAMERA is") + .that(cursor.getCount()) + .isEqualTo(1); + //PAGE_TOKEN will also be set since pageSize is not -1. + assertCursorExtras(cursor, EXTRA_ALBUM_ID, EXTRA_PAGE_SIZE, EXTRA_PAGE_TOKEN); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); } try (Cursor cursor = facade.queryMedia(/* generation */ -1, - ALBUM_ID_SCREENSHOTS, /* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(1); + ALBUM_ID_SCREENSHOTS, /* mimeType */ null, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with " + + "ALBUM_ID_SCREENSHOTS is") + .that(cursor.getCount()) + .isEqualTo(1); assertCursorExtras(cursor, EXTRA_ALBUM_ID); cursor.moveToFirst(); @@ -597,9 +796,15 @@ public class ExternalDbFacadeTest { } try (Cursor cursor = facade.queryMedia(/* generation */ -1, - ALBUM_ID_DOWNLOADS, /* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(1); - assertCursorExtras(cursor, EXTRA_ALBUM_ID); + ALBUM_ID_DOWNLOADS, /* mimeType */ null, /* pageSize*/ 10, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with " + + "ALBUM_ID_DOWNLOADS is") + .that(cursor.getCount()) + .isEqualTo(1); + //PAGE_TOKEN will also be set since pageSize is not -1. + assertCursorExtras(cursor, EXTRA_ALBUM_ID, EXTRA_PAGE_SIZE, EXTRA_PAGE_TOKEN); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID3, DATE_TAKEN_MS3); @@ -619,29 +824,169 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Number of rows on querying TABLES_FILES for all media is") + .that(cursor.getCount()) + .isEqualTo(1); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); } try (Cursor cursor = facade.queryMedia(/* generation */ 0, - ALBUM_ID_SCREENSHOTS, IMAGE_MIME_TYPES_QUERY)) { - assertThat(cursor.getCount()).isEqualTo(0); + ALBUM_ID_SCREENSHOTS, IMAGE_MIME_TYPES_QUERY, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with " + + "ALBUM_ID_SCREENSHOTS and IMAGE_MIME_TYPES_QUERY is") + .that(cursor.getCount()) + .isEqualTo(0); } try (Cursor cursor = facade.queryMedia(/* generation */ 0, - ALBUM_ID_CAMERA, VIDEO_MIME_TYPES_QUERY)) { - assertThat(cursor.getCount()).isEqualTo(0); + ALBUM_ID_CAMERA, VIDEO_MIME_TYPES_QUERY, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with ALBUM_ID_CAMERA " + + "and VIDEO_MIME_TYPES_QUERY is") + .that(cursor.getCount()) + .isEqualTo(0); + + } + + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + ALBUM_ID_CAMERA, IMAGE_MIME_TYPES_QUERY, /* pageSize*/ -1, + /* pageToken*/ null)) { + assertWithMessage( + "Number of rows on querying TABLES_FILES for media with ALBUM_ID_CAMERA " + + "and IMAGE_MIME_TYPES_QUERY is") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); + } + } + } + + @Test + public void testQueryMedia_withPageSize_returnsCorrectSortOrder() throws Exception { + try (DatabaseHelper helper = new TestDatabaseHelper(sIsolatedContext)) { + ExternalDbFacade facade = new ExternalDbFacade(sIsolatedContext, helper, + mock(VolumeCache.class)); + + // Insert 5 images with date_taken non-null + ContentValues cv = getContentValues(DATE_TAKEN_MS1, GENERATION_MODIFIED1); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_TAKEN, DATE_TAKEN_MS2); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_TAKEN, DATE_TAKEN_MS3); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_TAKEN, DATE_TAKEN_MS4); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_TAKEN, DATE_TAKEN_MS5); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + // Verify that media returned in descending order of date_taken, _id + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 2, + /* pageToken*/ null)) { + assertThat(cursor.getCount()).isEqualTo(2); + + cursor.moveToFirst(); + assertMediaColumns(facade, cursor, ID5, DATE_TAKEN_MS5); + + cursor.moveToNext(); + assertMediaColumns(facade, cursor, ID4, DATE_TAKEN_MS4); + } + + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 3, + /* pageToken*/ DATE_TAKEN_MS4 + "|" + ID4)) { + assertThat(cursor.getCount()).isEqualTo(3); + + cursor.moveToFirst(); + assertMediaColumns(facade, cursor, ID3, DATE_TAKEN_MS3); + + cursor.moveToNext(); + assertMediaColumns(facade, cursor, ID2, DATE_TAKEN_MS2); + + cursor.moveToNext(); + assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); } + } + } + @Test + public void testQueryMedia_withPageSizeMissingPageToken_returnsCorrectSortOrder() + throws Exception { + try (DatabaseHelper helper = new TestDatabaseHelper(sIsolatedContext)) { + ExternalDbFacade facade = new ExternalDbFacade(sIsolatedContext, helper, + mock(VolumeCache.class)); + + // Insert 5 images, 2 with date_taken non-null and 3 with date_taken null + ContentValues cv = getContentValues(DATE_TAKEN_MS1, GENERATION_MODIFIED1); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_TAKEN, DATE_TAKEN_MS2); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.remove(DATE_TAKEN); + + cv.put(MediaColumns.DATE_MODIFIED, DATE_MODIFIED_MS1); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_MODIFIED, DATE_MODIFIED_MS2); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + cv.put(MediaColumns.DATE_MODIFIED, DATE_MODIFIED_MS3); + helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); + + // Verify that media returned in descending order of date_taken, _id + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 2, + /* pageToken*/ null)) { + assertThat(cursor.getCount()).isEqualTo(2); + + cursor.moveToFirst(); + assertMediaColumns(facade, cursor, ID5, Long.valueOf(DATE_MODIFIED_MS3) * 1000); + + cursor.moveToNext(); + assertMediaColumns(facade, cursor, ID4, Long.valueOf(DATE_MODIFIED_MS2) * 1000); + } + + String pageToken = Long.valueOf(DATE_MODIFIED_MS2) * 1000 + "|" + ID4; + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 2, + /* pageToken*/ pageToken)) { + assertThat(cursor.getCount()).isEqualTo(2); + + cursor.moveToFirst(); + assertMediaColumns(facade, cursor, ID3, Long.valueOf(DATE_MODIFIED_MS1) * 1000); + + cursor.moveToNext(); + assertMediaColumns(facade, cursor, ID2, DATE_TAKEN_MS2); + } + + pageToken = DATE_TAKEN_MS2 + "|" + ID2; try (Cursor cursor = facade.queryMedia(/* generation */ 0, - ALBUM_ID_CAMERA, IMAGE_MIME_TYPES_QUERY)) { + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 2, + /* pageToken*/ pageToken)) { assertThat(cursor.getCount()).isEqualTo(1); cursor.moveToFirst(); assertMediaColumns(facade, cursor, ID1, DATE_TAKEN_MS1); } + + pageToken = DATE_MODIFIED_MS1 + "|" + ID1; + try (Cursor cursor = facade.queryMedia(/* generation */ 0, + /* albumId */ null, /* mimeType */ null, /* pageSize*/ 2, + /* pageToken*/ pageToken)) { + assertThat(cursor.getCount()).isEqualTo(0); + } } } @@ -688,7 +1033,8 @@ public class ExternalDbFacadeTest { final String mediaCollectionId = bundle.getString( MediaCollectionInfo.MEDIA_COLLECTION_ID); - assertThat(mediaCollectionId).isEqualTo(expectedMediaCollectionId); + assertWithMessage("The mediaCollectionId is") + .that(mediaCollectionId).isEqualTo(expectedMediaCollectionId); } } @@ -721,11 +1067,15 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Number of rows on querying TABLES_FILES with for all media is") + .that(cursor.getCount()) + .isEqualTo(1); } try (Cursor cursor = facade.queryAlbums(/* mimeType */ null)) { - assertThat(cursor.getCount()).isEqualTo(0); + assertWithMessage("Number of rows on querying TABLES_FILES for albums is") + .that(cursor.getCount()) + .isEqualTo(0); } } } @@ -779,11 +1129,17 @@ public class ExternalDbFacadeTest { helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv2)); try (Cursor cursor = queryAllMedia(facade)) { - assertThat(cursor.getCount()).isEqualTo(2); + assertWithMessage("Number of rows on querying TABLES_FILES for all media") + .that(cursor.getCount()) + .isEqualTo(2); } try (Cursor cursor = facade.queryAlbums(IMAGE_MIME_TYPES_QUERY)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage( + "Number of rows on querying TABLES_FILES for albums with " + + "IMAGE_MIME_TYPES_QUERY") + .that(cursor.getCount()) + .isEqualTo(1); // We verify the order of the albums only the image in camera is shown cursor.moveToNext(); @@ -795,10 +1151,14 @@ public class ExternalDbFacadeTest { @Test public void testOrderOfLocalAlbumIds() { // Camera, ScreenShots, Downloads - assertThat(ExternalDbFacade.LOCAL_ALBUM_IDS[0]).isEqualTo(ALBUM_ID_CAMERA); - assertThat(ExternalDbFacade.LOCAL_ALBUM_IDS[1]) + assertWithMessage("Local album at 0th index is") + .that(ExternalDbFacade.LOCAL_ALBUM_IDS[0]) + .isEqualTo(ALBUM_ID_CAMERA); + assertWithMessage("Local album at 1st index is") + .that(ExternalDbFacade.LOCAL_ALBUM_IDS[1]) .isEqualTo(ALBUM_ID_SCREENSHOTS); - assertThat(ExternalDbFacade.LOCAL_ALBUM_IDS[2]) + assertWithMessage("Local album at 2nd index is") + .that(ExternalDbFacade.LOCAL_ALBUM_IDS[2]) .isEqualTo(ALBUM_ID_DOWNLOADS); } @@ -808,14 +1168,14 @@ public class ExternalDbFacadeTest { cv1.put(MediaColumns.RELATIVE_PATH, ExternalDbFacade.RELATIVE_PATH_CAMERA); helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv1)); - // Insert in screenshots ablum + // Insert in screenshots album ContentValues cv2 = getContentValues(DATE_TAKEN_MS2, GENERATION_MODIFIED2); cv2.put( MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/" + Environment.DIRECTORY_SCREENSHOTS + "/"); helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv2)); - // Insert in download ablum + // Insert in download album ContentValues cv3 = getContentValues(DATE_TAKEN_MS3, GENERATION_MODIFIED3); cv3.put(MediaColumns.IS_DOWNLOAD, 1); helper.runWithTransaction(db -> db.insert(TABLE_FILES, null, cv3)); @@ -823,18 +1183,25 @@ public class ExternalDbFacadeTest { private static void assertDeletedMediaEmpty(ExternalDbFacade facade) { try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 0)) { - assertThat(cursor.getCount()).isEqualTo(0); + assertWithMessage( + "Number of rows in the deleted_media table is") + .that(cursor.getCount()).isEqualTo(0); } } private static void assertDeletedMedia(ExternalDbFacade facade, long id) { try (Cursor cursor = facade.queryDeletedMedia(/* generation */ 0)) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Number of rows in the deleted_media table is") + .that(cursor.getCount()) + .isEqualTo(1); cursor.moveToFirst(); - assertThat(cursor.getLong(0)).isEqualTo(id); - assertThat(cursor.getColumnName(0)).isEqualTo( - CloudMediaProviderContract.MediaColumns.ID); + assertWithMessage("Row id for the deleted media is") + .that(cursor.getLong(0)) + .isEqualTo(id); + assertWithMessage("Name of the column at index 0 is") + .that(cursor.getColumnName(0)) + .isEqualTo(CloudMediaProviderContract.MediaColumns.ID); } } @@ -865,24 +1232,44 @@ public class ExternalDbFacadeTest { int orientationIndex = cursor.getColumnIndex( CloudMediaProviderContract.MediaColumns.ORIENTATION); - assertThat(cursor.getLong(idIndex)).isEqualTo(id); - assertThat(cursor.getLong(dateTakenIndex)).isEqualTo(dateTakenMs); - assertThat(cursor.getLong(sizeIndex)).isEqualTo(SIZE); - assertThat(cursor.getString(mimeTypeIndex)).isEqualTo(mimeType); - assertThat(cursor.getLong(durationIndex)).isEqualTo(DURATION_MS); - assertThat(cursor.getInt(isFavoriteIndex)).isEqualTo(isFavorite); - assertThat(cursor.getInt(heightIndex)).isEqualTo(HEIGHT); - assertThat(cursor.getInt(widthIndex)).isEqualTo(WIDTH); - assertThat(cursor.getInt(orientationIndex)).isEqualTo(ORIENTATION); + assertWithMessage("MediaColumns.ID is") + .that(cursor.getLong(idIndex)) + .isEqualTo(id); + assertWithMessage("MediaColumns.DATE_TAKEN_MILLIS is") + .that(cursor.getLong(dateTakenIndex)) + .isEqualTo(dateTakenMs); + assertWithMessage("MediaColumns.SIZE_BYTES is") + .that(cursor.getLong(sizeIndex)) + .isEqualTo(SIZE); + assertWithMessage("MediaColumns.MIME_TYPE is") + .that(cursor.getString(mimeTypeIndex)) + .isEqualTo(mimeType); + assertWithMessage("MediaColumns.DURATION_MILLIS is") + .that(cursor.getLong(durationIndex)) + .isEqualTo(DURATION_MS); + assertWithMessage("MediaColumns.IS_FAVORITE is") + .that(cursor.getInt(isFavoriteIndex)) + .isEqualTo(isFavorite); + assertWithMessage("MediaColumns.HEIGHT is") + .that(cursor.getInt(heightIndex)) + .isEqualTo(HEIGHT); + assertWithMessage("MediaColumns.WIDTH is") + .that(cursor.getInt(widthIndex)) + .isEqualTo(WIDTH); + assertWithMessage("MediaColumns.ORIENTATION is") + .that(cursor.getInt(orientationIndex)) + .isEqualTo(ORIENTATION); } private static void assertCursorExtras(Cursor cursor, String... honoredArg) { final Bundle bundle = cursor.getExtras(); - assertThat(bundle.getString(EXTRA_MEDIA_COLLECTION_ID)) + assertWithMessage("Cursor extras is") + .that(bundle.getString(EXTRA_MEDIA_COLLECTION_ID)) .isEqualTo(MediaStore.getVersion(sIsolatedContext)); if (honoredArg != null) { - assertThat(bundle.getStringArrayList(EXTRA_HONORED_ARGS)) + assertWithMessage("Honored args are") + .that(bundle.getStringArrayList(EXTRA_HONORED_ARGS)) .containsExactlyElementsIn(Arrays.asList(honoredArg)); } } @@ -896,10 +1283,14 @@ public class ExternalDbFacadeTest { CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS); int countIndex = cursor.getColumnIndex(CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT); - assertThat(cursor.getString(displayNameIndex)).isEqualTo(displayName); - assertThat(cursor.getString(idIndex)).isNotNull(); - assertThat(cursor.getLong(dateTakenIndex)).isEqualTo(dateTakenMs); - assertThat(cursor.getLong(countIndex)).isEqualTo(count); + assertWithMessage("AlbumColumns.DISPLAY_NAME is") + .that(cursor.getString(displayNameIndex)).isEqualTo(displayName); + assertWithMessage("AlbumColumns.MEDIA_COVER_ID is") + .that(cursor.getString(idIndex)).isNotNull(); + assertWithMessage("AlbumColumns.DATE_TAKEN_MILLIS is") + .that(cursor.getLong(dateTakenIndex)).isEqualTo(dateTakenMs); + assertWithMessage("AlbumColumns.MEDIA_COUNT is") + .that(cursor.getLong(countIndex)).isEqualTo(count); } private static void assertMediaCollectionInfo(ExternalDbFacade facade, Bundle bundle, @@ -907,13 +1298,15 @@ public class ExternalDbFacadeTest { long generation = bundle.getLong(MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION); String mediaCollectionId = bundle.getString(MediaCollectionInfo.MEDIA_COLLECTION_ID); - assertThat(generation).isEqualTo(expectedGeneration); - assertThat(mediaCollectionId).isEqualTo(MediaStore.getVersion(sIsolatedContext)); + assertWithMessage("LAST_MEDIA_SYNC_GENERATION is") + .that(generation).isEqualTo(expectedGeneration); + assertWithMessage("MEDIA_COLLECTION_ID is") + .that(mediaCollectionId).isEqualTo(MediaStore.getVersion(sIsolatedContext)); } private static Cursor queryAllMedia(ExternalDbFacade facade) { return facade.queryMedia(/* generation */ -1, /* albumId */ null, - /* mimeType */ null); + /* mimeType */ null, /* pageSize*/ -1, /* pageToken*/ null); } private static ContentValues getContentValues(long dateTakenMs, long generation) { diff --git a/tests/src/com/android/providers/media/photopicker/data/PickerDatabaseHelperTest.java b/tests/src/com/android/providers/media/photopicker/data/PickerDatabaseHelperTest.java index 9d4eac2cf..6d1d4de64 100644 --- a/tests/src/com/android/providers/media/photopicker/data/PickerDatabaseHelperTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/PickerDatabaseHelperTest.java @@ -364,7 +364,7 @@ public class PickerDatabaseHelperTest { values = getBasicContentValues(); values.put(KEY_DATE_TAKEN_MS, -1); values.put(KEY_CLOUD_ID, CLOUD_ID); - assertThat(db.insert(MEDIA_TABLE, null, values)).isEqualTo(-1); + assertThat(db.insert(MEDIA_TABLE, null, values)).isEqualTo(1); // date_taken_ms=NULL for Album Media values = getBasicContentValues(); @@ -376,7 +376,7 @@ public class PickerDatabaseHelperTest { values = getBasicContentValues(); values.put(KEY_DATE_TAKEN_MS, -1); values.put(KEY_CLOUD_ID, CLOUD_ID); - assertThat(db.insert(ALBUM_MEDIA_TABLE, null, values)).isEqualTo(-1); + assertThat(db.insert(ALBUM_MEDIA_TABLE, null, values)).isEqualTo(1); } } diff --git a/tests/src/com/android/providers/media/photopicker/data/PickerDbFacadeTest.java b/tests/src/com/android/providers/media/photopicker/data/PickerDbFacadeTest.java index f38afe83b..d7838af0e 100644 --- a/tests/src/com/android/providers/media/photopicker/data/PickerDbFacadeTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/PickerDbFacadeTest.java @@ -22,8 +22,11 @@ import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_ import static com.android.providers.media.util.MimeUtils.getExtensionFromMimeType; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; import android.content.ContentValues; import android.content.Context; @@ -35,17 +38,23 @@ import android.provider.Column; import android.provider.ExportedSince; import android.provider.MediaStore.PickerMediaColumns; -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import com.android.providers.media.ProjectionHelper; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.sync.SyncTracker; +import com.android.providers.media.photopicker.sync.SyncTrackerRegistry; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; import java.io.File; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; @RunWith(AndroidJUnit4.class) public class PickerDbFacadeTest { @@ -86,14 +95,25 @@ public class PickerDbFacadeTest { private Context mContext; private ProjectionHelper mProjectionHelper; + @Mock + private SyncTracker mMockLocalSyncTracker; + @Mock + private SyncTracker mMockCloudSyncTracker; + @Before public void setUp() { - mContext = InstrumentationRegistry.getTargetContext(); + initMocks(this); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); dbPath.delete(); - mFacade = new PickerDbFacade(mContext, LOCAL_PROVIDER); + mFacade = new PickerDbFacade(mContext, new PickerSyncLockManager(), LOCAL_PROVIDER); mFacade.setCloudProvider(CLOUD_PROVIDER); mProjectionHelper = new ProjectionHelper(Column.class, ExportedSince.class); + + + // Inject mock trackers + SyncTrackerRegistry.setLocalSyncTracker(mMockLocalSyncTracker); + SyncTrackerRegistry.setCloudSyncTracker(mMockCloudSyncTracker); } @After @@ -101,6 +121,10 @@ public class PickerDbFacadeTest { if (mFacade != null) { mFacade.setCloudProvider(null); } + + // Reset mock trackers + SyncTrackerRegistry.setLocalSyncTracker(new SyncTracker()); + SyncTrackerRegistry.setCloudSyncTracker(new SyncTracker()); } @Test @@ -111,7 +135,10 @@ public class PickerDbFacadeTest { assertAddMediaOperation(LOCAL_PROVIDER, cursor1, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with cursor1 " + + "on LOCAL_PROVIDER.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 1); } @@ -120,7 +147,10 @@ public class PickerDbFacadeTest { assertAddMediaOperation(LOCAL_PROVIDER, cursor2, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after trying to update the same row with cursor2 " + + "on LOCAL_PROVIDER.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 2); } @@ -133,7 +163,9 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation on CLOUD_PROVIDER.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS); } @@ -147,7 +179,10 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cursor1, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with cursor1 on " + + "CLOUD_PROVIDER.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 1); } @@ -156,7 +191,10 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cursor2, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after trying to update the same row with cursor2 " + + "on CLOUD_PROVIDER.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 2); } @@ -171,7 +209,11 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with:\nlocalCursor having " + + "localId = " + LOCAL_ID + ", followed by\ncloudCursor having " + + "localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -186,13 +228,48 @@ public class PickerDbFacadeTest { assertAddMediaOperation(LOCAL_PROVIDER, localCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with:\ncloudCursor having " + + "localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + ", followed by" + + "\ncloudCursor having localId = " + LOCAL_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 1); } } @Test + public void testMediaSortOrder() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, DATE_TAKEN_MS); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, null, DATE_TAKEN_MS); + final Cursor cursor3 = getLocalMediaCursor(LOCAL_ID_2, DATE_TAKEN_MS + 1); + + assertAddMediaOperation(LOCAL_PROVIDER, cursor1, 1); + assertAddMediaOperation(CLOUD_PROVIDER, cursor2, 1); + assertAddMediaOperation(LOCAL_PROVIDER, cursor3, 1); + + try (Cursor cr = queryMediaAll()) { + assertWithMessage( + "Unexpected number of media on queryMediaAll() after adding 2 " + + "localMediaCursor and 1 cloudMediaCursor to " + + LOCAL_PROVIDER + " and " + CLOUD_PROVIDER + " respectively.") + .that(cr.getCount()).isEqualTo(/* expected= */ 3); + + cr.moveToFirst(); + // Latest items should show up first. + assertCloudMediaCursor(cr, LOCAL_ID_2, DATE_TAKEN_MS + 1); + + cr.moveToNext(); + // If the date taken is the same for 2 or more items, they should be sorted in the order + // of their insertion in the database with the latest row inserted first. + assertCloudMediaCursor(cr, CLOUD_ID_1, DATE_TAKEN_MS); + + cr.moveToNext(); + assertCloudMediaCursor(cr, LOCAL_ID_1, DATE_TAKEN_MS); + } + } + + @Test public void testAddLocalAlbumMedia() { Cursor cursor1 = getAlbumMediaCursor(LOCAL_ID, /* cloud id */ null, DATE_TAKEN_MS + 1); Cursor cursor2 = getAlbumMediaCursor(LOCAL_ID, /* cloud id */ null, DATE_TAKEN_MS + 2); @@ -200,7 +277,11 @@ public class PickerDbFacadeTest { assertAddAlbumMediaOperation(LOCAL_PROVIDER, cursor1, 1, ALBUM_ID); try (Cursor cr = queryAlbumMedia(ALBUM_ID, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia after adding albumMediaCursor having localId" + + " = " + + LOCAL_ID + " cloudId = " + null + " to " + LOCAL_PROVIDER) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 1); } @@ -210,7 +291,11 @@ public class PickerDbFacadeTest { assertAddAlbumMediaOperation(LOCAL_PROVIDER, cursor2, 1, ALBUM_ID); try (Cursor cr = queryAlbumMedia(ALBUM_ID, true)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia after resetting and updating the same row " + + "with albumMediaCursor having localId = " + + LOCAL_ID + " cloudId = " + null) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 2); } @@ -224,7 +309,11 @@ public class PickerDbFacadeTest { assertAddAlbumMediaOperation(CLOUD_PROVIDER, cursor1, 1, ALBUM_ID); try (Cursor cr = queryAlbumMedia(ALBUM_ID, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia after adding albumMediaCursor having localId" + + " = " + + null + " cloudId = " + CLOUD_ID + " to " + CLOUD_PROVIDER) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 1); } @@ -234,13 +323,50 @@ public class PickerDbFacadeTest { assertAddAlbumMediaOperation(CLOUD_PROVIDER, cursor2, 1, ALBUM_ID); try (Cursor cr = queryAlbumMedia(ALBUM_ID, false)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia after resetting and updating the same row " + + "with albumMediaCursor having localId = " + + null + " cloudId = " + CLOUD_PROVIDER) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 2); } } @Test + public void testAddCloudAlbumMediaWhileCloudSyncIsRunning() { + + + doReturn(Collections.singletonList(new CompletableFuture<>())) + .when(mMockCloudSyncTracker) + .pendingSyncFutures(); + + Cursor cursor1 = getAlbumMediaCursor(/* local id */ null, CLOUD_ID, DATE_TAKEN_MS + 1); + + assertAddAlbumMediaOperation(CLOUD_PROVIDER, cursor1, 1, ALBUM_ID); + + try (Cursor cr = queryAlbumMedia(ALBUM_ID, false)) { + assertWithMessage( + "Unexpected number of albumMedia after adding albumMediaCursor having localId" + + " = " + + null + " cloudId = " + CLOUD_ID + " to " + CLOUD_PROVIDER) + .that(cr.getCount()).isEqualTo(1); + cr.moveToFirst(); + assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 1); + } + + // These files should also be in the media table since we're pretending that + // we have a cloud sync running. + try (Cursor cr = queryMediaAll()) { + assertWithMessage( + "Unexpected number of media on querying all media with cloud sync running.") + .that(cr.getCount()).isEqualTo(1); + cr.moveToFirst(); + assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 1); + } + } + + @Test public void testAddCloudAlbumMediaAvailableOnDevice() { // Add local row for a media item in media table. final Cursor localCursor = getLocalMediaCursor(LOCAL_ID, DATE_TAKEN_MS); @@ -254,7 +380,9 @@ public class PickerDbFacadeTest { // Assert that preference was given to the local media item over cloud media item at the // time of insertion in album_media table. try (Cursor albumCursor = queryAlbumMedia(ALBUM_ID, false)) { - assertThat(albumCursor.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia on querying " + ALBUM_ID) + .that(albumCursor.getCount()).isEqualTo(1); albumCursor.moveToFirst(); assertCloudMediaCursor(albumCursor, LOCAL_ID, DATE_TAKEN_MS); } @@ -271,26 +399,64 @@ public class PickerDbFacadeTest { // Assert that cloud media metadata was inserted in the database as local_id points to a // deleted item. try (Cursor albumCursor = queryAlbumMedia(ALBUM_ID, false)) { - assertThat(albumCursor.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of albumMedia on querying " + ALBUM_ID) + .that(albumCursor.getCount()).isEqualTo(1); albumCursor.moveToFirst(); assertCloudMediaCursor(albumCursor, CLOUD_ID, DATE_TAKEN_MS); } } @Test + public void testAlbumMediaSortOrder() { + final Cursor cursor1 = getAlbumMediaCursor(null, CLOUD_ID_1, DATE_TAKEN_MS); + final Cursor cursor2 = getAlbumMediaCursor(LOCAL_ID_1, null, DATE_TAKEN_MS); + final Cursor cursor3 = getAlbumMediaCursor(null, CLOUD_ID_2, DATE_TAKEN_MS + 1); + + assertAddAlbumMediaOperation(CLOUD_PROVIDER, cursor1, 1, ALBUM_ID); + assertAddAlbumMediaOperation(LOCAL_PROVIDER, cursor2, 1, ALBUM_ID); + assertAddAlbumMediaOperation(CLOUD_PROVIDER, cursor3, 1, ALBUM_ID); + + try (Cursor cr = queryAlbumMedia(ALBUM_ID, false)) { + assertWithMessage( + "Unexpected number of media on queryMediaAll() after adding 2 " + + "cloudAlbumMediaCursor and 1 localAlbumMediaCursor to " + + CLOUD_PROVIDER + " and " + LOCAL_PROVIDER + " respectively.") + .that(cr.getCount()).isEqualTo(/* expected= */ 3); + + cr.moveToFirst(); + // Latest items should show up first. + assertCloudMediaCursor(cr, CLOUD_ID_2, DATE_TAKEN_MS + 1); + + cr.moveToNext(); + // If the date taken is the same for 2 or more items, they should be sorted in the order + // of their insertion in the database with the latest row inserted first. + assertCloudMediaCursor(cr, LOCAL_ID_1, DATE_TAKEN_MS); + + cr.moveToNext(); + assertCloudMediaCursor(cr, CLOUD_ID_1, DATE_TAKEN_MS); + } + } + + @Test public void testRemoveLocal() throws Exception { Cursor localCursor = getLocalMediaCursor(LOCAL_ID, DATE_TAKEN_MS); assertAddMediaOperation(LOCAL_PROVIDER, localCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with local media cursor " + + "localCursor.") + .that(cr.getCount()).isEqualTo(1); } assertRemoveMediaOperation(LOCAL_PROVIDER, getDeletedMediaCursor(LOCAL_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on local provider.") + .that(cr.getCount()).isEqualTo(0); } } @@ -303,7 +469,12 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "one cloudCursor where " + + "\nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -311,7 +482,9 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(LOCAL_PROVIDER, getDeletedMediaCursor(LOCAL_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on local provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS); } @@ -324,13 +497,18 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with cloud media cursor " + + "cloudCursor.") + .that(cr.getCount()).isEqualTo(1); } assertRemoveMediaOperation(CLOUD_PROVIDER, getDeletedMediaCursor(CLOUD_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on cloud provider.") + .that(cr.getCount()).isEqualTo(0); } } @@ -347,7 +525,14 @@ public class PickerDbFacadeTest { } try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with two cloudCursor where " + + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + + "1" + + "\ncloudCursor2 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + + "2" + ) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID + "1", DATE_TAKEN_MS + 1); } @@ -355,7 +540,9 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(CLOUD_PROVIDER, getDeletedMediaCursor(CLOUD_ID + "1"), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on cloud provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID + "2", DATE_TAKEN_MS + 2); } @@ -370,7 +557,12 @@ public class PickerDbFacadeTest { assertAddMediaOperation(LOCAL_PROVIDER, localCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "one cloudCursor where " + + "\nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -378,7 +570,9 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(CLOUD_PROVIDER, getDeletedMediaCursor(CLOUD_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on cloud provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -398,7 +592,11 @@ public class PickerDbFacadeTest { } try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with two localCursor where " + + "\nlocalCursor1 has localId = " + LOCAL_ID + + "\nlocalCursor2 has localId = " + LOCAL_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS + 2); } @@ -406,7 +604,9 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(LOCAL_PROVIDER, getDeletedMediaCursor(LOCAL_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on local provider.") + .that(cr.getCount()).isEqualTo(0); } } @@ -419,7 +619,12 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor2, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with two cloudCursor where " + + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + + "\ncloudCursor2 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + ) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 2); } @@ -427,7 +632,9 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(CLOUD_PROVIDER, getDeletedMediaCursor(CLOUD_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media after removeMediaOperation on cloud provider.") + .that(cr.getCount()).isEqualTo(0); } } @@ -442,7 +649,13 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor2, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "two cloudCursor, where \nlocalCursor has localId = " + + LOCAL_ID + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + + CLOUD_ID + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -450,7 +663,11 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(LOCAL_PROVIDER, getDeletedMediaCursor(LOCAL_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after removeMediaOperation deleting media with " + + "localId =" + + LOCAL_ID + " from local provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS + 2); } @@ -458,7 +675,59 @@ public class PickerDbFacadeTest { assertRemoveMediaOperation(CLOUD_PROVIDER, getDeletedMediaCursor(CLOUD_ID), 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media after removeMediaOperation deleting media with " + + "cloudId =" + + CLOUD_ID + " from cloud provider.") + .that(cr.getCount()).isEqualTo(0); + } + } + + @Test + public void testRemoveMedia_withLatestDateTakenMillis() { + Cursor localCursor = getLocalMediaCursor(LOCAL_ID, DATE_TAKEN_MS); + Cursor cloudCursor1 = getCloudMediaCursor(CLOUD_ID, LOCAL_ID, DATE_TAKEN_MS + 1); + + assertAddMediaOperation(LOCAL_PROVIDER, localCursor, 1); + assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor1, 1); + + try (Cursor cr = queryMediaAll()) { + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "one cloudCursor where " + + "\nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); + cr.moveToFirst(); + assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); + } + + try (PickerDbFacade.DbWriteOperation operation = + mFacade.beginRemoveMediaOperation(CLOUD_PROVIDER)) { + assertWriteOperation(operation, getDeletedMediaCursor(CLOUD_ID), /* writeCount */ 1); + assertWithMessage( + "Unexpected value for the firstDateTakenMillis in the columns affected by DB " + + "write operation.") + .that(operation.getFirstDateTakenMillis()).isEqualTo(DATE_TAKEN_MS + 1); + operation.setSuccess(); + } + + try (PickerDbFacade.DbWriteOperation operation = + mFacade.beginRemoveMediaOperation(LOCAL_PROVIDER)) { + assertWriteOperation(operation, getDeletedMediaCursor(LOCAL_ID), /* writeCount */ 1); + assertWithMessage( + "Unexpected value for the FirstDateTakenMillis in the columns affected by DB " + + "write operation.") + .that(operation.getFirstDateTakenMillis()).isEqualTo(DATE_TAKEN_MS); + operation.setSuccess(); + } + + try (Cursor cr = queryMediaAll()) { + assertWithMessage( + "Unexpected number of media after removeMediaOperation on cloud provider then" + + " on local provider.") + .that(cr.getCount()).isEqualTo(0); } } @@ -475,7 +744,14 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor2, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "two cloudCursor, where \nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + + "1" + + "\ncloudCursor1 has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID + + "2") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -483,12 +759,15 @@ public class PickerDbFacadeTest { assertResetMediaOperation(LOCAL_PROVIDER, null, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after resetMediaOperation on local provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); // Verify that local_id was deleted and either of cloudCursor1 or cloudCursor2 // was promoted - assertThat(cr.getString(1)).isNotNull(); + assertWithMessage("Failed to delete local_Id.") + .that(cr.getString(1)).isNotNull(); } } @@ -501,7 +780,12 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "one cloudCursor where " + + "\nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -509,7 +793,9 @@ public class PickerDbFacadeTest { assertResetMediaOperation(CLOUD_PROVIDER, null, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after resetMediaOperation on cloud provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); } @@ -524,21 +810,30 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media after addMediaOperation with one localCursor and " + + "one cloudCursor where " + + "\nlocalCursor has localId = " + LOCAL_ID + + "\ncloudCursor has localId = " + LOCAL_ID + ", cloudId = " + CLOUD_ID) + .that(cr.getCount()).isEqualTo(1); } PickerDbFacade.QueryFilterBuilder qfbBefore = new PickerDbFacade.QueryFilterBuilder(5); qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS - 1); qfbBefore.setId(5); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media with dateTakenBeforeMs set to DATE_TAKEN_MS - 1.") + .that(cr.getCount()).isEqualTo(0); } PickerDbFacade.QueryFilterBuilder qfbAfter = new PickerDbFacade.QueryFilterBuilder(5); qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS + 1); qfbAfter.setId(5); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of media with dateTakenAfterMs set to DATE_TAKEN_MS + 1.") + .that(cr.getCount()).isEqualTo(0); } } @@ -558,7 +853,8 @@ public class PickerDbFacadeTest { qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS); qfbBefore.setId(2); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media with Id set to 2.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + "1", DATE_TAKEN_MS); @@ -568,7 +864,8 @@ public class PickerDbFacadeTest { qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS); qfbAfter.setId(1); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media with Id set to 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + "2", DATE_TAKEN_MS); @@ -589,7 +886,10 @@ public class PickerDbFacadeTest { qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS + 1); qfbBefore.setId(0); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media with limit set to 1 and dateTakenBeforeMs set to " + + "DATE_TAKEN_MS + 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + "3", DATE_TAKEN_MS); @@ -599,7 +899,10 @@ public class PickerDbFacadeTest { qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS - 1); qfbAfter.setId(0); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media with limit set to 1 and dateTakenAfterMs set to " + + "DATE_TAKEN_MS - 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + "3", DATE_TAKEN_MS); @@ -607,7 +910,8 @@ public class PickerDbFacadeTest { try (Cursor cr = mFacade.queryMediaForUi( new PickerDbFacade.QueryFilterBuilder(1).build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media with limit set to 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + "3", DATE_TAKEN_MS); @@ -630,12 +934,14 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfbAll = new PickerDbFacade.QueryFilterBuilder(1000); qfbAll.setSizeBytes(10); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage("Unexpected number of media with sizeBytes set to 10.") + .that(cr.getCount()).isEqualTo(2); } qfbAll.setSizeBytes(1); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of media with sizeBytes set to 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, MP4_VIDEO_MIME_TYPE); @@ -647,14 +953,20 @@ public class PickerDbFacadeTest { qfbAfter.setId(0); qfbAfter.setSizeBytes(10); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media with sizeBytes set to 10 and dateTakenAfterMs set" + + " to DATE_TAKEN_MS - 1.") + .that(cr.getCount()).isEqualTo(2); } qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS - 1); qfbAfter.setId(0); qfbAfter.setSizeBytes(1); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media with sizeBytes set to 1 and dateTakenAfterMs set " + + "to DATE_TAKEN_MS - 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, MP4_VIDEO_MIME_TYPE); @@ -666,14 +978,20 @@ public class PickerDbFacadeTest { qfbBefore.setId(0); qfbBefore.setSizeBytes(10); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of media with sizeBytes set to 10 and dateTakenBeforeMs " + + "set to DATE_TAKEN_MS + 1.") + .that(cr.getCount()).isEqualTo(2); } qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS + 1); qfbBefore.setId(0); qfbBefore.setSizeBytes(1); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of media with sizeBytes set to 1 and dateTakenBeforeMs set" + + " to DATE_TAKEN_MS + 1.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, MP4_VIDEO_MIME_TYPE); @@ -712,26 +1030,34 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfbAll = new PickerDbFacade.QueryFilterBuilder(1000); qfbAll.setMimeTypes(new String[]{"*/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(6); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"*/*\"}") + .that(cr.getCount()).isEqualTo(6); } qfbAll.setMimeTypes(new String[]{"image/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(4); - - assertAllMediaCursor(cr, new String[] {CLOUD_ID_2, CLOUD_ID_1, LOCAL_ID_2, - CLOUD_ID_3}, new long[] {DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS, - DATE_TAKEN_MS - 1}, new String[] {GIF_IMAGE_MIME_TYPE, PNG_IMAGE_MIME_TYPE, + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"image/*\"}") + .that(cr.getCount()).isEqualTo(4); + + assertAllMediaCursor(cr, + new String[]{CLOUD_ID_2, CLOUD_ID_1, LOCAL_ID_2, CLOUD_ID_3}, + new long[]{DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS - 1}, + new String[]{GIF_IMAGE_MIME_TYPE, PNG_IMAGE_MIME_TYPE, JPEG_IMAGE_MIME_TYPE, PNG_IMAGE_MIME_TYPE}); } qfbAll.setMimeTypes(new String[]{"video/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"video/*\"}") + .that(cr.getCount()).isEqualTo(2); - assertAllMediaCursor(cr, new String[] {LOCAL_ID_3, LOCAL_ID_1}, new long[] - {DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, new String[] {MP4_VIDEO_MIME_TYPE, - WEBM_VIDEO_MIME_TYPE}); + assertAllMediaCursor(cr, + new String[]{LOCAL_ID_3, LOCAL_ID_1}, + new long[]{DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, + new String[]{MP4_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); } // Verify after @@ -740,29 +1066,40 @@ public class PickerDbFacadeTest { qfbAfter.setId(0); qfbAfter.setMimeTypes(new String[]{"image/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(3); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"image/*\"} " + + "and date taken after set to DATE_TAKEN_MS") + .that(cr.getCount()).isEqualTo(3); } qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS - 1); qfbAfter.setId(0); qfbAfter.setMimeTypes(new String[]{PNG_IMAGE_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{PNG_IMAGE_MIME_TYPE} and date taken after set to DATE_TAKEN_MS - 1") + .that(cr.getCount()).isEqualTo(2); - assertAllMediaCursor(cr, new String[] {CLOUD_ID_1, CLOUD_ID_3}, new long[] - {DATE_TAKEN_MS, DATE_TAKEN_MS - 1}, new String[] {PNG_IMAGE_MIME_TYPE, - PNG_IMAGE_MIME_TYPE}); + assertAllMediaCursor(cr, + new String[]{CLOUD_ID_1, CLOUD_ID_3}, + new long[]{DATE_TAKEN_MS, DATE_TAKEN_MS - 1}, + new String[]{PNG_IMAGE_MIME_TYPE, PNG_IMAGE_MIME_TYPE}); } qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS - 1); qfbAfter.setId(0); qfbAfter.setMimeTypes(new String[]{"video/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"video/*\"} " + + "and date taken after set to DATE_TAKEN_MS - 1") + .that(cr.getCount()).isEqualTo(2); - assertAllMediaCursor(cr, new String[] {LOCAL_ID_3, LOCAL_ID_1}, new long[] - {DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, new String[] {MP4_VIDEO_MIME_TYPE, - WEBM_VIDEO_MIME_TYPE}); + assertAllMediaCursor(cr, + new String[]{LOCAL_ID_3, LOCAL_ID_1}, + new long[]{DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, + new String[]{MP4_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); } // Verify before @@ -771,14 +1108,20 @@ public class PickerDbFacadeTest { qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{"*/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(5); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"*/*\"} and " + + "date taken before set to DATE_TAKEN_MS + 1") + .that(cr.getCount()).isEqualTo(5); } qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS + 1); qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{"video/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"video/*\"} " + + "and date taken before set to DATE_TAKEN_MS + 1") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID_1, DATE_TAKEN_MS, WEBM_VIDEO_MIME_TYPE); @@ -788,22 +1131,31 @@ public class PickerDbFacadeTest { qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{"video/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"video/*\"} " + + "and date taken before set to DATE_TAKEN_MS + 2") + .that(cr.getCount()).isEqualTo(2); - assertAllMediaCursor(cr, new String[] {LOCAL_ID_3, LOCAL_ID_1}, new long[] - {DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, new String[] {MP4_VIDEO_MIME_TYPE, - WEBM_VIDEO_MIME_TYPE}); + assertAllMediaCursor(cr, + new String[]{LOCAL_ID_3, LOCAL_ID_1}, + new long[]{DATE_TAKEN_MS + 1, DATE_TAKEN_MS}, + new String[]{MP4_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); } qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS + 1); qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{PNG_IMAGE_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{PNG_IMAGE_MIME_TYPE} and date taken before set to DATE_TAKEN_MS +" + + " 1") + .that(cr.getCount()).isEqualTo(2); - assertAllMediaCursor(cr, new String[] {CLOUD_ID_1, CLOUD_ID_3}, new long[] - {DATE_TAKEN_MS, DATE_TAKEN_MS - 1}, new String[] {PNG_IMAGE_MIME_TYPE , - PNG_IMAGE_MIME_TYPE}); + assertAllMediaCursor(cr, + new String[]{CLOUD_ID_1, CLOUD_ID_3}, + new long[]{DATE_TAKEN_MS, DATE_TAKEN_MS - 1}, + new String[]{PNG_IMAGE_MIME_TYPE, PNG_IMAGE_MIME_TYPE}); } } @@ -847,21 +1199,29 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfbAll = new PickerDbFacade.QueryFilterBuilder(1000); qfbAll.setMimeTypes(new String[]{"*/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(8); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"*/*\"}") + .that(cr.getCount()).isEqualTo(8); } qfbAll.setMimeTypes(new String[]{"image/*", PNG_IMAGE_MIME_TYPE, MP4_VIDEO_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(6); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"image/*\"," + + "PNG_IMAGE_MIME_TYPE ,PNG_IMAGE_MIME_TYPE}") + .that(cr.getCount()).isEqualTo(6); } qfbAll.setMimeTypes(new String[]{GIF_IMAGE_MIME_TYPE, MPEG_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(3); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{GIF_IMAGE_MIME_TYPE, MPEG_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}") + .that(cr.getCount()).isEqualTo(3); - assertAllMediaCursor(cr, new String[] {CLOUD_ID_3, CLOUD_ID_2, LOCAL_ID_1}, - new long[] {DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[] { + assertAllMediaCursor(cr, new String[]{CLOUD_ID_3, CLOUD_ID_2, LOCAL_ID_1}, + new long[]{DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[]{ MPEG_VIDEO_MIME_TYPE, GIF_IMAGE_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); } @@ -872,7 +1232,10 @@ public class PickerDbFacadeTest { qfbAfter.setId(0); qfbAfter.setMimeTypes(new String[]{"video/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(4); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"video/*\"} " + + "and date taken after set to DATE_TAKEN_MS - 1") + .that(cr.getCount()).isEqualTo(4); } qfbAfter.setDateTakenAfterMs(DATE_TAKEN_MS - 1); @@ -880,10 +1243,14 @@ public class PickerDbFacadeTest { qfbAfter.setMimeTypes(new String[]{GIF_IMAGE_MIME_TYPE, MPEG_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE, M4V_VIDEO_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(3); - - assertAllMediaCursor(cr, new String[] {CLOUD_ID_3, CLOUD_ID_2, LOCAL_ID_1}, - new long[] {DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[] { + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{GIF_IMAGE_MIME_TYPE, MPEG_VIDEO_MIME_TYPE, WEBM_VIDEO_MIME_TYPE, " + + "M4V_VIDEO_MIME_TYPE} and date taken after set to DATE_TAKEN_MS - 1") + .that(cr.getCount()).isEqualTo(3); + + assertAllMediaCursor(cr, new String[]{CLOUD_ID_3, CLOUD_ID_2, LOCAL_ID_1}, + new long[]{DATE_TAKEN_MS, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[]{ MPEG_VIDEO_MIME_TYPE, GIF_IMAGE_MIME_TYPE, WEBM_VIDEO_MIME_TYPE}); } @@ -891,7 +1258,11 @@ public class PickerDbFacadeTest { qfbAfter.setId(0); qfbAfter.setMimeTypes(new String[]{GIF_IMAGE_MIME_TYPE, MP4_VIDEO_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbAfter.build())) { - assertThat(cr.getCount()).isEqualTo(3); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{GIF_IMAGE_MIME_TYPE, MP4_VIDEO_MIME_TYPE} and date taken after " + + "set to DATE_TAKEN_MS - 1") + .that(cr.getCount()).isEqualTo(3); } // Verify before @@ -900,14 +1271,20 @@ public class PickerDbFacadeTest { qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{"*/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(7); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"*/*\"} and " + + "date taken before set to DATE_TAKEN_MS + 1") + .that(cr.getCount()).isEqualTo(7); } qfbBefore.setDateTakenBeforeMs(DATE_TAKEN_MS); qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{"image/*"}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"image/*\"} " + + "and date taken before set to DATE_TAKEN_MS") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID_4, DATE_TAKEN_MS - 1, PNG_IMAGE_MIME_TYPE); @@ -917,10 +1294,14 @@ public class PickerDbFacadeTest { qfbBefore.setId(0); qfbBefore.setMimeTypes(new String[]{MP4_VIDEO_MIME_TYPE, GIF_IMAGE_MIME_TYPE}); try (Cursor cr = mFacade.queryMediaForUi(qfbBefore.build())) { - assertThat(cr.getCount()).isEqualTo(3); - - assertAllMediaCursor(cr, new String[] {LOCAL_ID_4, CLOUD_ID_2, LOCAL_ID_3}, - new long[] {DATE_TAKEN_MS + 1, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[] { + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{MP4_VIDEO_MIME_TYPE, GIF_IMAGE_MIME_TYPE} and date taken before " + + "set to DATE_TAKEN_MS + 2") + .that(cr.getCount()).isEqualTo(3); + + assertAllMediaCursor(cr, new String[]{LOCAL_ID_4, CLOUD_ID_2, LOCAL_ID_3}, + new long[]{DATE_TAKEN_MS + 1, DATE_TAKEN_MS, DATE_TAKEN_MS}, new String[]{ MP4_VIDEO_MIME_TYPE, GIF_IMAGE_MIME_TYPE, MP4_VIDEO_MIME_TYPE}); } } @@ -942,14 +1323,20 @@ public class PickerDbFacadeTest { qfbAll.setMimeTypes(new String[]{"*/*"}); qfbAll.setSizeBytes(10); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to {\"*/*\"} and size " + + "filter set to 10 bytes") + .that(cr.getCount()).isEqualTo(2); } // mime_type and size filter matches none qfbAll.setMimeTypes(new String[]{WEBM_VIDEO_MIME_TYPE}); qfbAll.setSizeBytes(1); try (Cursor cr = mFacade.queryMediaForUi(qfbAll.build())) { - assertThat(cr.getCount()).isEqualTo(0); + assertWithMessage( + "Unexpected number of rows with mime_type filter set to " + + "{WEBM_VIDEO_MIME_TYPE} and size filter set to 1 byte") + .that(cr.getCount()).isEqualTo(0); } } @@ -973,20 +1360,22 @@ public class PickerDbFacadeTest { } // Assert one projection column - final String[] oneProjection = new String[] { PickerMediaColumns.DATE_TAKEN }; + final String[] oneProjection = new String[]{PickerMediaColumns.DATE_TAKEN}; try (Cursor cr = mFacade.queryMediaIdForApps(CLOUD_PROVIDER, CLOUD_ID, oneProjection)) { assertThat(cr.getCount()).isEqualTo(1); cr.moveToFirst(); - assertThat(cr.getLong(cr.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) + assertWithMessage( + "Unexpected value of PickerMediaColumns.DATE_TAKEN with cloud provider.") + .that(cr.getLong(cr.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) .isEqualTo(DATE_TAKEN_MS); } // Assert invalid projection column final String invalidColumn = "testInvalidColumn"; - final String[] invalidProjection = new String[] { + final String[] invalidProjection = new String[]{ PickerMediaColumns.DATE_TAKEN, invalidColumn }; @@ -996,9 +1385,13 @@ public class PickerDbFacadeTest { assertThat(cr.getCount()).isEqualTo(1); cr.moveToFirst(); - assertThat(cr.getLong(cr.getColumnIndex(invalidColumn))) + assertWithMessage( + "Unexpected value of the invalidColumn with cloud provider.") + .that(cr.getLong(cr.getColumnIndex(invalidColumn))) .isEqualTo(0); - assertThat(cr.getLong(cr.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) + assertWithMessage( + "Unexpected value of PickerMediaColumns.DATE_TAKEN with cloud provider.") + .that(cr.getLong(cr.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) .isEqualTo(DATE_TAKEN_MS); } } @@ -1020,7 +1413,9 @@ public class PickerDbFacadeTest { new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryMediaForUi.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertThrows( IllegalArgumentException.class, @@ -1063,7 +1458,9 @@ public class PickerDbFacadeTest { try (Cursor cr = mFacade.queryAlbumMediaForUi( localQfb.setAlbumId(ALBUM_ID).build(), LOCAL_PROVIDER)) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of rows on queryAlbumMediaForUi with local provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertThrows( IllegalArgumentException.class, @@ -1081,7 +1478,9 @@ public class PickerDbFacadeTest { try (Cursor cr = mFacade.queryAlbumMediaForUi( cloudQfb.setAlbumId(ALBUM_ID).build(), CLOUD_PROVIDER)) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryAlbumMediaForUi with cloud provider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertThrows( IllegalArgumentException.class, @@ -1104,7 +1503,10 @@ public class PickerDbFacadeTest { assertAddMediaOperation(CLOUD_PROVIDER, cloudCursor, 1); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryMediaAll with both local and cloud " + + "provider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS); @@ -1117,7 +1519,9 @@ public class PickerDbFacadeTest { mFacade.setCloudProvider(null); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(1); + assertWithMessage( + "Unexpected number of rows on queryMediaAll after hiding cloud provider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID, DATE_TAKEN_MS); @@ -1127,7 +1531,9 @@ public class PickerDbFacadeTest { mFacade.setCloudProvider(CLOUD_PROVIDER); try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryMediaAll after un-hiding cloud provider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID, DATE_TAKEN_MS); @@ -1168,12 +1574,17 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfb = new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(4); + assertWithMessage( + "Unexpected number of rows on queryMediaForUi with no filter.") + .that(cr.getCount()).isEqualTo(4); } qfb.setIsFavorite(true); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryMediaForUi with isFavorite filter set to " + + "true.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudMediaCursor(cr, CLOUD_ID + 1, DATE_TAKEN_MS); @@ -1213,18 +1624,101 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfb = new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(4); + assertWithMessage("Unexpected number of rows on queryMediaForUi without any filter.") + .that(cr.getCount()).isEqualTo(4); + } + + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums without any filter for cloud " + + "provider.") + .that(cr.getCount()).isEqualTo(2); + cr.moveToFirst(); + assertCloudAlbumCursor(cr, + ALBUM_ID_FAVORITES, + ALBUM_ID_FAVORITES, + LOCAL_ID + "1", + DATE_TAKEN_MS, + /* count */ 2); + cr.moveToNext(); + assertCloudAlbumCursor(cr, + ALBUM_ID_VIDEOS, + ALBUM_ID_VIDEOS, + LOCAL_ID + "1", + DATE_TAKEN_MS, + /* count */ 2); + } + } + + @Test + public void testGetVideosAlbumWithMimeTypesFilter() throws Exception { + Cursor localCursor1 = getMediaCursor(LOCAL_ID + "1", DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, SIZE_BYTES, MP4_VIDEO_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + Cursor localCursor2 = getMediaCursor(LOCAL_ID + "2", DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, SIZE_BYTES, JPEG_IMAGE_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ true); + Cursor cloudCursor1 = getMediaCursor(CLOUD_ID + "1", DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, SIZE_BYTES, JPEG_IMAGE_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + Cursor cloudCursor2 = getMediaCursor(CLOUD_ID + "2", DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, SIZE_BYTES, MP4_VIDEO_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + + try (PickerDbFacade.DbWriteOperation operation = + mFacade.beginAddMediaOperation(LOCAL_PROVIDER)) { + assertWriteOperation(operation, localCursor1, 1); + assertWriteOperation(operation, localCursor2, 1); + operation.setSuccess(); + } + try (PickerDbFacade.DbWriteOperation operation = + mFacade.beginAddMediaOperation(CLOUD_PROVIDER)) { + assertWriteOperation(operation, cloudCursor1, 1); + assertWriteOperation(operation, cloudCursor2, 1); + operation.setSuccess(); + } + + PickerDbFacade.QueryFilterBuilder qfb = + new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); + try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { + assertWithMessage("Unexpected number of rows on queryMediaForUi without any filter.") + .that(cr.getCount()).isEqualTo(4); } - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums without any filter for cloud " + + "provider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, ALBUM_ID_FAVORITES, + LOCAL_ID + "2", + DATE_TAKEN_MS, + /* count */ 1); + cr.moveToNext(); + assertCloudAlbumCursor(cr, + ALBUM_ID_VIDEOS, + ALBUM_ID_VIDEOS, LOCAL_ID + "1", DATE_TAKEN_MS, /* count */ 2); + } + + qfb.setMimeTypes(new String[]{MP4_VIDEO_MIME_TYPE, JPEG_IMAGE_MIME_TYPE}); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), /* cloudProvider*/ CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums without any filter for cloud " + + "provider.") + .that(cr.getCount()).isEqualTo(2); + cr.moveToFirst(); + assertCloudAlbumCursor(cr, + ALBUM_ID_FAVORITES, + ALBUM_ID_FAVORITES, + LOCAL_ID + "2", + DATE_TAKEN_MS, + /* count */ 1); cr.moveToNext(); assertCloudAlbumCursor(cr, ALBUM_ID_VIDEOS, @@ -1233,6 +1727,21 @@ public class PickerDbFacadeTest { DATE_TAKEN_MS, /* count */ 2); } + + qfb.setMimeTypes(new String[]{GIF_IMAGE_MIME_TYPE, JPEG_IMAGE_MIME_TYPE}); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), /* cloudProvider*/ CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with mime type filter set to " + + "{GIF_IMAGE_MIME_TYPE, JPEG_IMAGE_MIME_TYPE} for cloud provider.") + .that(cr.getCount()).isEqualTo(1); + cr.moveToFirst(); + assertCloudAlbumCursor(cr, + ALBUM_ID_FAVORITES, + ALBUM_ID_FAVORITES, + LOCAL_ID + "2", + DATE_TAKEN_MS, + /* count */ 1); + } } @Test @@ -1266,11 +1775,15 @@ public class PickerDbFacadeTest { PickerDbFacade.QueryFilterBuilder qfb = new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(4); + assertWithMessage("Unexpected number of rows on queryMediaForUi without any filter.") + .that(cr.getCount()).isEqualTo(4); } - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums without any filter for cloud " + + "provider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, @@ -1288,8 +1801,25 @@ public class PickerDbFacadeTest { } qfb.setMimeTypes(IMAGE_MIME_TYPES_QUERY); - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(1); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), /* cloudProvider*/ null)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with mime type filter set to " + + "IMAGE_MIME_TYPES_QUERY and cloudProvider set to null.") + .that(cr.getCount()).isEqualTo(1); + cr.moveToFirst(); + assertCloudAlbumCursor(cr, + ALBUM_ID_FAVORITES, + ALBUM_ID_FAVORITES, + CLOUD_ID + "1", + DATE_TAKEN_MS, + /* count */ 1); + } + + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with mime type filter set to " + + "{IMAGE_MIME_TYPES_QUERY} with cloudProvider.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, @@ -1300,8 +1830,11 @@ public class PickerDbFacadeTest { } qfb.setMimeTypes(VIDEO_MIME_TYPES_QUERY); - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with mime type filter set to " + + "VIDEO_MIME_TYPES_QUERY with cloudProvider.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, @@ -1319,8 +1852,11 @@ public class PickerDbFacadeTest { } qfb.setMimeTypes(new String[]{"foo"}); - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(0); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with mime type filter set to " + + "{\"foo\"} and not null cloudProvider.") + .that(cr.getCount()).isEqualTo(1); } } @@ -1362,24 +1898,33 @@ public class PickerDbFacadeTest { new PickerDbFacade.QueryFilterBuilder(/* limit */ 1000); // Verify that we see all(local + cloud) items. try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(4); + assertWithMessage("Unexpected number of rows on queryMediaForUi without any filter.") + .that(cr.getCount()).isEqualTo(4); } // Verify that we only see local items with isLocalOnly=true qfb.setIsLocalOnly(true); try (Cursor cr = mFacade.queryMediaForUi(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage( + "Unexpected number of rows on queryMediaForUi with isLocalOnly set to true.") + .that(cr.getCount()).isEqualTo(2); cr.moveToNext(); - assertThat(cr.getString(cr.getColumnIndex(MediaColumns.ID))).isEqualTo(LOCAL_ID + "2"); + assertWithMessage("Unexpected value of MediaColumns.ID at cursor.") + .that(cr.getString(cr.getColumnIndex(MediaColumns.ID))).isEqualTo( + LOCAL_ID + "2"); cr.moveToNext(); - assertThat(cr.getString(cr.getColumnIndex(MediaColumns.ID))).isEqualTo(LOCAL_ID + "1"); + assertWithMessage("Unexpected value of MediaColumns.ID at cursor.") + .that(cr.getString(cr.getColumnIndex(MediaColumns.ID))).isEqualTo( + LOCAL_ID + "1"); } // Verify that we see all available merged albums and their respective media count qfb.setIsLocalOnly(false); - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(2); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), CLOUD_PROVIDER)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with isLocalOnly set to false.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, @@ -1398,8 +1943,11 @@ public class PickerDbFacadeTest { qfb.setIsLocalOnly(true); // Verify that with isLocalOnly=true, we only see one album with only one local item. - try (Cursor cr = mFacade.getMergedAlbums(qfb.build())) { - assertThat(cr.getCount()).isEqualTo(1); + try (Cursor cr = mFacade.getMergedAlbums(qfb.build(), /* cloudProvider */ null)) { + assertWithMessage( + "Unexpected number of rows on getMergedAlbums with isLocalOnly set to true " + + "and cloudProvider set to null.") + .that(cr.getCount()).isEqualTo(1); cr.moveToFirst(); assertCloudAlbumCursor(cr, ALBUM_ID_FAVORITES, @@ -1427,7 +1975,8 @@ public class PickerDbFacadeTest { } try (Cursor cr = queryMediaAll()) { - assertThat(cr.getCount()).isEqualTo(2); + assertWithMessage("Unexpected number of rows on queryMediaForUi.") + .that(cr.getCount()).isEqualTo(2); cr.moveToFirst(); assertCloudMediaCursor(cr, LOCAL_ID + 1, MP4_VIDEO_MIME_TYPE); @@ -1468,17 +2017,20 @@ public class PickerDbFacadeTest { ContentValues values = new ContentValues(); values.put(PickerDbFacade.KEY_STANDARD_MIME_TYPE_EXTENSION, MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP); - assertThat(operation.execute(LOCAL_ID, values)).isTrue(); + assertWithMessage("Failed to update media with LOCAL_ID.") + .that(operation.execute(LOCAL_ID, values)).isTrue(); operation.setSuccess(); } try (Cursor cursor = queryMediaAll()) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of rows after update operation.") + .that(cursor.getCount()).isEqualTo(1); // Assert that STANDARD_MIME_TYPE_EXTENSION has been updated cursor.moveToFirst(); - assertThat(cursor.getInt(cursor.getColumnIndex( - MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) + assertWithMessage("Failed to update STANDARD_MIME_TYPE_EXTENSION.") + .that(cursor.getInt(cursor.getColumnIndex( + MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) .isEqualTo(MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP); } } @@ -1499,17 +2051,20 @@ public class PickerDbFacadeTest { ContentValues values = new ContentValues(); values.put(PickerDbFacade.KEY_STANDARD_MIME_TYPE_EXTENSION, MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP); - assertThat(operation.execute(CLOUD_ID, values)).isFalse(); + assertWithMessage("Unexpected, should have failed to update media with CLOUD_ID.") + .that(operation.execute(CLOUD_ID, values)).isFalse(); operation.setSuccess(); } try (Cursor cursor = queryMediaAll()) { - assertThat(cursor.getCount()).isEqualTo(1); + assertWithMessage("Unexpected number of rows after update operation.") + .that(cursor.getCount()).isEqualTo(1); // Assert that STANDARD_MIME_TYPE_EXTENSION is same as before cursor.moveToFirst(); - assertThat(cursor.getInt(cursor.getColumnIndex( - MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) + assertWithMessage("Unexpected STANDARD_MIME_TYPE_EXTENSION, not same as before.") + .that(cursor.getInt(cursor.getColumnIndex( + MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) .isEqualTo(STANDARD_MIME_TYPE_EXTENSION); } } @@ -1571,21 +2126,22 @@ public class PickerDbFacadeTest { private static void assertWriteOperation(PickerDbFacade.DbWriteOperation operation, Cursor cursor, int expectedWriteCount) { final int writeCount = operation.execute(cursor); - assertThat(writeCount).isEqualTo(expectedWriteCount); + assertWithMessage("Unexpected write count on operation.execute(cursor).") + .that(writeCount).isEqualTo(expectedWriteCount); } // TODO(b/190713331): s/id/CloudMediaProviderContract#MediaColumns#ID/ private static Cursor getDeletedMediaCursor(String id) { MatrixCursor c = - new MatrixCursor(new String[] {"id"}); - c.addRow(new String[] {id}); + new MatrixCursor(new String[]{"id"}); + c.addRow(new String[]{id}); return c; } private static Cursor getMediaCursor(String id, long dateTakenMs, long generationModified, String mediaStoreUri, long sizeBytes, String mimeType, int standardMimeTypeExtension, boolean isFavorite) { - String[] projectionKey = new String[] { + String[] projectionKey = new String[]{ MediaColumns.ID, MediaColumns.MEDIA_STORE_URI, MediaColumns.DATE_TAKEN_MILLIS, @@ -1600,7 +2156,7 @@ public class PickerDbFacadeTest { MediaColumns.ORIENTATION, }; - String[] projectionValue = new String[] { + String[] projectionValue = new String[]{ id, mediaStoreUri, String.valueOf(dateTakenMs), @@ -1629,7 +2185,7 @@ public class PickerDbFacadeTest { String mimeType, int standardMimeTypeExtension) { String[] projectionKey = - new String[] { + new String[]{ MediaColumns.ID, MediaColumns.MEDIA_STORE_URI, MediaColumns.DATE_TAKEN_MILLIS, @@ -1641,7 +2197,7 @@ public class PickerDbFacadeTest { }; String[] projectionValue = - new String[] { + new String[]{ id, mediaStoreUri, String.valueOf(dateTakenMs), @@ -1694,15 +2250,21 @@ public class PickerDbFacadeTest { private static void assertCloudAlbumCursor(Cursor cursor, String albumId, String displayName, String mediaCoverId, long dateTakenMs, long mediaCount) { - assertThat(cursor.getString(cursor.getColumnIndex(AlbumColumns.ID))) + assertWithMessage("Unexpected value of AlbumColumns.ID for cloud album cursor.") + .that(cursor.getString(cursor.getColumnIndex(AlbumColumns.ID))) .isEqualTo(albumId); - assertThat(cursor.getString(cursor.getColumnIndex(AlbumColumns.DISPLAY_NAME))) + assertWithMessage("Unexpected value of AlbumColumns.DISPLAY_NAME for cloud album cursor.") + .that(cursor.getString(cursor.getColumnIndex(AlbumColumns.DISPLAY_NAME))) .isEqualTo(displayName); - assertThat(cursor.getString(cursor.getColumnIndex(AlbumColumns.MEDIA_COVER_ID))) + assertWithMessage("Unexpected value of AlbumColumns.MEDIA_COVER_ID for cloud album cursor.") + .that(cursor.getString(cursor.getColumnIndex(AlbumColumns.MEDIA_COVER_ID))) .isEqualTo(mediaCoverId); - assertThat(cursor.getLong(cursor.getColumnIndex(AlbumColumns.DATE_TAKEN_MILLIS))) + assertWithMessage( + "Unexpected value of AlbumColumns.DATE_TAKEN_MILLIS for cloud album cursor.") + .that(cursor.getLong(cursor.getColumnIndex(AlbumColumns.DATE_TAKEN_MILLIS))) .isEqualTo(dateTakenMs); - assertThat(cursor.getLong(cursor.getColumnIndex(AlbumColumns.MEDIA_COUNT))) + assertWithMessage("Unexpected value of AlbumColumns.MEDIA_COUNT for cloud album cursor.") + .that(cursor.getLong(cursor.getColumnIndex(AlbumColumns.MEDIA_COUNT))) .isEqualTo(mediaCount); } @@ -1711,28 +2273,43 @@ public class PickerDbFacadeTest { final String localData = getData(LOCAL_PROVIDER, displayName); final String cloudData = getData(CLOUD_PROVIDER, displayName); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.ID))) + assertWithMessage("Unexpected value of MediaColumns.ID for the cloud media cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.ID))) .isEqualTo(id); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.AUTHORITY))) + assertWithMessage("Unexpected value of MediaColumns.AUTHORITY for the cloud media cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.AUTHORITY))) .isEqualTo(id.startsWith(LOCAL_ID) ? LOCAL_PROVIDER : CLOUD_PROVIDER); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.DATA))) + assertWithMessage("Unexpected value of MediaColumns.DATA for the cloud media cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.DATA))) .isEqualTo(id.startsWith(LOCAL_ID) ? localData : cloudData); } private static void assertCloudMediaCursor(Cursor cursor, String id, long dateTakenMs) { assertCloudMediaCursor(cursor, id, MP4_VIDEO_MIME_TYPE); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE))) + assertWithMessage("Unexpected value of MediaColumns.MIME_TYPE for the cloud media cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE))) .isEqualTo(MP4_VIDEO_MIME_TYPE); - assertThat(cursor.getInt(cursor.getColumnIndex(MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) + assertWithMessage( + "Unexpected value of MediaColumns.STANDARD_MIME_TYPE_EXTENSION for the cloud " + + "media cursor.") + .that(cursor.getInt( + cursor.getColumnIndex(MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) .isEqualTo(STANDARD_MIME_TYPE_EXTENSION); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.DATE_TAKEN_MILLIS))) + assertWithMessage( + "Unexpected value of MediaColumns.DATE_TAKEN_MILLIS for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.DATE_TAKEN_MILLIS))) .isEqualTo(dateTakenMs); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.SYNC_GENERATION))) + assertWithMessage( + "Unexpected value of MediaColumns.SYNC_GENERATION for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.SYNC_GENERATION))) .isEqualTo(GENERATION_MODIFIED); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.SIZE_BYTES))) + assertWithMessage("Unexpected value of MediaColumns.SIZE_BYTES for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.SIZE_BYTES))) .isEqualTo(SIZE_BYTES); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.DURATION_MILLIS))) + assertWithMessage( + "Unexpected value of MediaColumns.DURATION_MILLIS for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.DURATION_MILLIS))) .isEqualTo(DURATION_MS); } @@ -1740,17 +2317,30 @@ public class PickerDbFacadeTest { Cursor cursor, String id, long dateTakenMs, String mimeType) { assertCloudMediaCursor(cursor, id, mimeType); - assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE))) + assertWithMessage("Unexpected value for MediaColumns.MIME_TYPE for the cloud media cursor.") + .that(cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE))) .isEqualTo(mimeType); - assertThat(cursor.getInt(cursor.getColumnIndex(MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) + assertWithMessage( + "Unexpected value for MediaColumns.STANDARD_MIME_TYPE_EXTENSION for the cloud " + + "media cursor.") + .that(cursor.getInt( + cursor.getColumnIndex(MediaColumns.STANDARD_MIME_TYPE_EXTENSION))) .isEqualTo(STANDARD_MIME_TYPE_EXTENSION); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.DATE_TAKEN_MILLIS))) + assertWithMessage( + "Unexpected value for MediaColumns.DATE_TAKEN_MILLIS for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.DATE_TAKEN_MILLIS))) .isEqualTo(dateTakenMs); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.SYNC_GENERATION))) + assertWithMessage( + "Unexpected value for MediaColumns.SYNC_GENERATION for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.SYNC_GENERATION))) .isEqualTo(GENERATION_MODIFIED); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.SIZE_BYTES))) + assertWithMessage( + "Unexpected value for MediaColumns.SIZE_BYTES for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.SIZE_BYTES))) .isEqualTo(SIZE_BYTES); - assertThat(cursor.getLong(cursor.getColumnIndex(MediaColumns.DURATION_MILLIS))) + assertWithMessage( + "Unexpected value for MediaColumns.DURATION_MILLIS for the cloud media cursor.") + .that(cursor.getLong(cursor.getColumnIndex(MediaColumns.DURATION_MILLIS))) .isEqualTo(DURATION_MS); } @@ -1758,8 +2348,11 @@ public class PickerDbFacadeTest { Cursor cursor, String[] mediaIds, long[] dateTakenMs, String[] mimeTypes) { int mediaCount = cursor.getCount(); for (int mediaNo = 0; mediaNo < mediaCount; mediaNo = mediaNo + 1) { - if (mediaNo == 0) cursor.moveToFirst(); - else cursor.moveToNext(); + if (mediaNo == 0) { + cursor.moveToFirst(); + } else { + cursor.moveToNext(); + } assertCloudMediaCursor(cursor, mediaIds[mediaNo], dateTakenMs[mediaNo], mimeTypes[mediaNo]); } @@ -1770,23 +2363,42 @@ public class PickerDbFacadeTest { final String localData = getData(LOCAL_PROVIDER, displayName); final String cloudData = getData(CLOUD_PROVIDER, displayName); - assertThat(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.DISPLAY_NAME))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.DISPLAY_NAME for the media store cursor.") + .that(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.DISPLAY_NAME))) .isEqualTo(displayName); - assertThat(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.DATA))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.DATA for the media store cursor.") + .that(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.DATA))) .isEqualTo(id.startsWith(LOCAL_ID) ? localData : cloudData); - assertThat(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.MIME_TYPE))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.MIME_TYPE for the media store cursor.") + .that(cursor.getString(cursor.getColumnIndex(PickerMediaColumns.MIME_TYPE))) .isEqualTo(MP4_VIDEO_MIME_TYPE); - assertThat(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.DATE_TAKEN for the media store cursor.") + .that(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.DATE_TAKEN))) .isEqualTo(dateTakenMs); - assertThat(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.SIZE))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.SIZE for the media store cursor.") + .that(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.SIZE))) .isEqualTo(SIZE_BYTES); - assertThat(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.DURATION_MILLIS))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.DURATION_MILLIS for the media store " + + "cursor.") + .that(cursor.getLong(cursor.getColumnIndex(PickerMediaColumns.DURATION_MILLIS))) .isEqualTo(DURATION_MS); - assertThat(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.HEIGHT))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.HEIGHT for the media store cursor.") + .that(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.HEIGHT))) .isEqualTo(HEIGHT); - assertThat(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.WIDTH))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.WIDTH for the media store cursor.") + .that(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.WIDTH))) .isEqualTo(WIDTH); - assertThat(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.ORIENTATION))) + assertWithMessage( + "Unexpected value for PickerMediaColumns.ORIENTATION for the media store cursor.") + .that(cursor.getInt(cursor.getColumnIndex(PickerMediaColumns.ORIENTATION))) .isEqualTo(ORIENTATION); } -}
\ No newline at end of file +} diff --git a/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java b/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java index bce370be7..189f12217 100644 --- a/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java @@ -62,6 +62,22 @@ public class SelectionTest { } @Test + public void testAddSelectedItem_orderedSelection() { + try { + enableOrderedSelection(); + final Item item1 = generateFakeImageItem("1"); + final Item item2 = generateFakeImageItem("2"); + + mSelection.addSelectedItem(item1); + mSelection.addSelectedItem(item2); + assertThat(mSelection.getSelectedItemOrder(item1).getValue().intValue()).isEqualTo(1); + assertThat(mSelection.getSelectedItemOrder(item2).getValue().intValue()).isEqualTo(2); + } finally { + disableOrderedSelection(); + } + } + + @Test public void testDeleteSelectedItem() { final String id = "1"; final Item item = generateFakeImageItem(id); @@ -76,6 +92,56 @@ public class SelectionTest { } @Test + public void testDeleteSelectedItem_orderedSelection() { + try { + enableOrderedSelection(); + final Item item1 = generateFakeImageItem("1"); + final Item item2 = generateFakeImageItem("2"); + final Item item3 = generateFakeImageItem("3"); + + mSelection.addSelectedItem(item1); + mSelection.addSelectedItem(item2); + mSelection.addSelectedItem(item3); + + assertThat(mSelection.getSelectedItemOrder(item1).getValue().intValue()).isEqualTo(1); + assertThat(mSelection.getSelectedItemOrder(item2).getValue().intValue()).isEqualTo(2); + assertThat(mSelection.getSelectedItemOrder(item3).getValue().intValue()).isEqualTo(3); + + mSelection.removeSelectedItem(item1); + + assertThat(mSelection.getSelectedItemOrder(item2).getValue().intValue()).isEqualTo(1); + assertThat(mSelection.getSelectedItemOrder(item3).getValue().intValue()).isEqualTo(2); + + mSelection.removeSelectedItem(item3); + + assertThat(mSelection.getSelectedItemOrder(item2).getValue().intValue()).isEqualTo(1); + } finally { + disableOrderedSelection(); + } + } + + @Test + public void testGetSelectedItems_orderedSelection() { + try { + enableOrderedSelection(); + final Item item1 = generateFakeImageItem("1"); + final Item item2 = generateFakeImageItem("2"); + final Item item3 = generateFakeImageItem("3"); + + mSelection.addSelectedItem(item1); + mSelection.addSelectedItem(item2); + mSelection.addSelectedItem(item3); + + List<Item> itemsSorted = mSelection.getSelectedItems(); + assertThat(itemsSorted.get(0).getId()).isEqualTo("1"); + assertThat(itemsSorted.get(1).getId()).isEqualTo("2"); + assertThat(itemsSorted.get(2).getId()).isEqualTo("3"); + } finally { + disableOrderedSelection(); + } + } + + @Test public void testClearSelectedItem() { final String id = "1"; final Item item = generateFakeImageItem(id); @@ -164,6 +230,39 @@ public class SelectionTest { } @Test + public void testParseValuesFromIntent_orderedSelection() { + final Intent intent = new Intent(); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, true); + + mSelection.parseSelectionValuesFromIntent(intent); + + assertThat(mSelection.isSelectionOrdered()).isTrue(); + } + + @Test + public void testParseValuesFromIntent_InvalidOrderedSelectionGetContent_throwsException() { + final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, true); + + try { + mSelection.parseSelectionValuesFromIntent(intent); + fail("Ordered selection not allowed for GET_CONTENT"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + @Test + public void testParseValuesFromIntent_OrderedSelectionDisabledInPermissionMode() { + final Intent intent = new Intent(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, true); + + mSelection.parseSelectionValuesFromIntent(intent); + + assertThat(mSelection.isSelectionOrdered()).isFalse(); + } + + @Test public void testParseValuesFromIntent_allowMultipleNotSupported() { final Intent intent = new Intent(); intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); @@ -276,4 +375,16 @@ public class SelectionTest { return generateJpegItem(id, dateTakenMs, /* generationModified */ 1L); } + + private void enableOrderedSelection() { + final Intent intent = new Intent(); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, true); + mSelection.parseSelectionValuesFromIntent(intent); + } + + private void disableOrderedSelection() { + final Intent intent = new Intent(); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, false); + mSelection.parseSelectionValuesFromIntent(intent); + } }
\ No newline at end of file diff --git a/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java index e96d45c51..ef6c88540 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java @@ -25,7 +25,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotSelected; @@ -39,12 +38,15 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class ActiveProfileButtonTest extends PhotoPickerBaseTest { private static final int PROFILE_BUTTON = R.id.profile_button; @@ -127,11 +129,19 @@ public class ActiveProfileButtonTest extends PhotoPickerBaseTest { // Check the text on the button. It should be "Switch to work" onView(withText(R.string.picker_work_profile)).check(matches(isDisplayed())); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_ENABLED); + // verify clicking it does not open error dialog onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed())).perform(click()); onView(withText(R.string.picker_profile_admin_title)).check(doesNotExist()); onView(withText(R.string.picker_profile_work_paused_title)).check(doesNotExist()); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_CLICK); + // Clicking the button, it takes a few ms to change the string. // Wait 100ms to be sure. // TODO(b/201982046): Replace with more stable workaround using Espresso idling resources diff --git a/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java b/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java index 1f4f3068e..5cc870d71 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java @@ -17,6 +17,7 @@ package com.android.providers.media.photopicker.espresso; import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; @@ -28,6 +29,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.android.providers.media.photopicker.espresso.OverflowMenuUtils.assertOverflowMenuNotShown; import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemDisplayed; +import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotDisplayed; import static org.hamcrest.Matchers.allOf; @@ -36,12 +38,14 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class AlbumsTabTest extends PhotoPickerBaseTest { @@ -51,7 +55,6 @@ public class AlbumsTabTest extends PhotoPickerBaseTest { public ActivityScenarioRule<PhotoPickerTestActivity> mRule = new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent()); - @Ignore("b/227478958 Odd failure to verify Downloads album") @Test public void testAlbumGrid() { // Goto Albums page @@ -78,10 +81,16 @@ public class AlbumsTabTest extends PhotoPickerBaseTest { onView(withId(PICKER_TAB_RECYCLERVIEW_ID)) .check(new RecyclerViewItemCountAssertion(expectedAlbumCount)); + // Verify albums tab click and albums loaded UI events + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_TAB_ALBUMS_OPEN); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_ALBUMS, expectedAlbumCount); + // First album is Camera - assertItemContentInAlbumList(/* position */ 0, R.string.picker_category_videos); + assertItemContentInAlbumList(/* position */ 0, R.string.picker_category_camera); // Second album is Videos - assertItemContentInAlbumList(/* position */ 1, R.string.picker_category_camera); + assertItemContentInAlbumList(/* position */ 1, R.string.picker_category_videos); // Third album is Downloads assertItemContentInAlbumList(/* position */ 2, R.string.picker_category_downloads); @@ -94,21 +103,39 @@ public class AlbumsTabTest extends PhotoPickerBaseTest { private void assertItemContentInAlbumList(int position, int albumNameResId) { // Verify the components are shown on the album item assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.album_name); - assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.item_count); + // As per the current requirements , hiding album's item count. + // In case if in future we need to show album's item count , we also have to assert its + // correct count with the visibility of album's item count block. + assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.item_count); assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.icon_thumbnail); // Verify we have the album in the list onView(allOf(withText(albumNameResId), isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))) .check(matches(isDisplayed())); - // Verify the position of the album name matches the correct order + // Verify the position of the album name matches the correct order AND click the album onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID) .atPositionOnView(position, R.id.album_name)) - .check(matches(withText(albumNameResId))); + .check(matches(withText(albumNameResId))) + .perform(click()); - // Verify the item count is correct - onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID) - .atPositionOnView(position, R.id.item_count)) - .check(matches(withText("1 item"))); + // Verify album click UI event + UiEventLoggerTestUtils.verifyLogWithInstanceId(mRule, getUiEventForAlbumId(albumNameResId)); + + // Go back to the Albums tab + pressBack(); + } + + private PhotoPickerEvent getUiEventForAlbumId(int albumNameResId) { + switch (albumNameResId) { + case R.string.picker_category_videos: + return PhotoPickerEvent.PHOTO_PICKER_ALBUM_VIDEOS_OPEN; + case R.string.picker_category_camera: + return PhotoPickerEvent.PHOTO_PICKER_ALBUM_CAMERA_OPEN; + case R.string.picker_category_downloads: + return PhotoPickerEvent.PHOTO_PICKER_ALBUM_DOWNLOADS_OPEN; + default: + throw new IllegalArgumentException("Unexpected albumNameResId: " + albumNameResId); + } } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java index 5ad647b6c..f06963ac8 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java @@ -27,12 +27,15 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class BlockedByAdminProfileButtonTest extends PhotoPickerBaseTest { @BeforeClass @@ -53,11 +56,19 @@ public class BlockedByAdminProfileButtonTest extends PhotoPickerBaseTest { // Check the text on the button. It should be "Switch to personal" onView(withText(R.string.picker_personal_profile)).check(matches(isDisplayed())); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_DISABLED); + // Verify onClick shows a dialog onView(withId(profileButtonId)).check(matches(isDisplayed())).perform(click()); onView(withText(R.string.picker_profile_admin_title)).check(matches(isDisplayed())); onView(withText(R.string.picker_profile_admin_msg_from_work)).check(matches(isDisplayed())); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_PROFILE_SWITCH_BUTTON_CLICK); + onView(withText(android.R.string.ok)).check(matches(isDisplayed())).perform(click()); } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/BottomSheetIdlingResource.java b/tests/src/com/android/providers/media/photopicker/espresso/BottomSheetIdlingResource.java index 693b4454a..a36531f8b 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/BottomSheetIdlingResource.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/BottomSheetIdlingResource.java @@ -99,7 +99,8 @@ public class BottomSheetIdlingResource implements IdlingResource { * given {@link ActivityScenarioRule}. * @param scenario */ - public static BottomSheetIdlingResource register(ActivityScenario scenario) { + public static <T extends PhotoPickerTestActivity> BottomSheetIdlingResource register( + ActivityScenario<T> scenario) { final BottomSheetIdlingResource[] idlingResources = new BottomSheetIdlingResource[1]; scenario.onActivity( (activity -> { diff --git a/tests/src/com/android/providers/media/photopicker/espresso/DisabledAccessibilityTest.java b/tests/src/com/android/providers/media/photopicker/espresso/DisabledAccessibilityTest.java new file mode 100644 index 000000000..3e17e2f40 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/espresso/DisabledAccessibilityTest.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker.espresso; + +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isNotSelected; +import static androidx.test.espresso.matcher.ViewMatchers.isSelected; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.providers.media.photopicker.espresso.BottomSheetTestUtils.assertBottomSheetState; +import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.customSwipeDownPartialScreen; +import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.swipeLeftAndWait; +import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.swipeRightAndWait; +import static com.android.providers.media.photopicker.espresso.OrientationUtils.setLandscapeOrientation; +import static com.android.providers.media.photopicker.espresso.OrientationUtils.setPortraitOrientation; +import static com.android.providers.media.photopicker.espresso.OverflowMenuUtils.assertOverflowMenuNotShown; +import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView; +import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.longClickItem; + +import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED; +import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED; +import static com.google.common.truth.Truth.assertThat; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.not; + +import android.app.Activity; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.action.ViewActions; +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; + +import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * {@link DisabledAccessibilityTest} tests the + * {@link com.android.providers.media.photopicker.PhotoPickerActivity} behaviors that require it to + * launch in partial screen. + */ +@RunOnlyOnPostsubmit +@RunWith(AndroidJUnit4ClassRunner.class) +public class DisabledAccessibilityTest extends PhotoPickerBaseTest { + + private ActivityScenario<PhotoPickerAccessibilityDisabledTestActivity> mScenario; + + /** + * Note - {@link ActivityScenario#launchActivityForResult(Class)} launches the activity with the + * intent action {@link android.content.Intent#ACTION_MAIN}. + */ + @Before + public void launchActivity() { + mScenario = ActivityScenario.launchActivityForResult( + PhotoPickerAccessibilityDisabledTestActivity.class); + } + + @After + public void closeActivity() { + if (mScenario != null) { + mScenario.close(); + } + } + + @Test + @Ignore("b/313489524") + // TODO(b/313489524): Fix flaky orientation change in the photo picker espresso tests + public void testBottomSheetState() { + // Bottom sheet assertions are different based on the orientation + setPortraitOrientation(mScenario); + + // Register bottom sheet idling resource so that we don't read bottom sheet state when + // in between changing states + final BottomSheetIdlingResource bottomSheetIdlingResource = + BottomSheetIdlingResource.register(mScenario); + + try { + // Single select PhotoPicker is launched in partial screen mode + bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); + onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); + onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); + mScenario.onActivity( + activity -> { + assertBottomSheetState(activity, STATE_COLLAPSED); + }); + + // Swipe up and check that the PhotoPicker is in full screen mode + bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); + onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeUp()); + mScenario.onActivity( + activity -> { + assertBottomSheetState(activity, STATE_EXPANDED); + }); + + // Swipe down and check that the PhotoPicker is in partial screen mode + bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); + onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeDown()); + mScenario.onActivity( + activity -> { + assertBottomSheetState(activity, STATE_COLLAPSED); + }); + + // Swiping down on drag bar is not strong enough as closing the bottomsheet requires a + // stronger downward swipe using espresso. + // Simply swiping down on R.id.bottom_sheet throws an error from espresso, as the view + // is only 60% visible, but downward swipe is only successful on an element which is 90% + // visible. + onView(withId(R.id.bottom_sheet)).perform(customSwipeDownPartialScreen()); + } finally { + IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); + } + assertThat(mScenario.getResult().getResultCode()).isEqualTo(Activity.RESULT_CANCELED); + } + + @Test + @Ignore("b/313489524") + // TODO(b/313489524): Fix flaky orientation change in the photo picker espresso tests + public void testBottomSheetStateInLandscapeMode() { + // Bottom sheet assertions are different based on the orientation + setLandscapeOrientation(mScenario); + + // Register bottom sheet idling resource so that we don't read bottom sheet state when + // in between changing states + final BottomSheetIdlingResource bottomSheetIdlingResource = + BottomSheetIdlingResource.register(mScenario); + + try { + // Single select PhotoPicker is launched in full screen mode in Landscape orientation + mScenario.onActivity( + activity -> { + assertBottomSheetState(activity, STATE_EXPANDED); + }); + + // Swiping down on drag bar / privacy text is not strong enough as closing the + // bottomsheet requires a stronger downward swipe using espresso. + onView(withId(R.id.bottom_sheet)).perform(ViewActions.swipeDown()); + } finally { + IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); + } + assertThat(mScenario.getResult().getResultCode()).isEqualTo(Activity.RESULT_CANCELED); + } + + @Test + public void testTabSwiping() throws Exception { + // Bottom sheet assertions are different based on the orientation + setPortraitOrientation(mScenario); + + onView(withId(TAB_LAYOUT_ID)).check(matches(isDisplayed())); + + // If we want to swipe the viewPager2 of tabContainerFragment in Espresso tests, at least 90 + // percent of the view's area is displayed to the user. Swipe up the bottom Sheet to make + // sure it is in full Screen mode. + // Register bottom sheet idling resource so that we don't read bottom sheet state when + // in between changing states + final BottomSheetIdlingResource bottomSheetIdlingResource = + BottomSheetIdlingResource.register(mScenario); + + try { + // Single select PhotoPicker is launched in partial screen mode + bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); + mScenario.onActivity(activity -> { + assertBottomSheetState(activity, STATE_COLLAPSED); + }); + + // Swipe up and check that the PhotoPicker is in full screen mode. + onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); + onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeUp()); + bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); + mScenario.onActivity( + activity -> { + assertBottomSheetState(activity, STATE_EXPANDED); + }); + } finally { + IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); + } + + try (ViewPager2IdlingResource idlingResource = + ViewPager2IdlingResource.register(mScenario, TAB_VIEW_PAGER_ID)) { + // Swipe left, we should see albums tab + swipeLeftAndWait(TAB_VIEW_PAGER_ID); + + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isNotSelected())); + // Verify Camera album is shown, we are in albums tab + onView(allOf(withText(R.string.picker_category_camera), + isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check( + matches(isDisplayed())); + + // Swipe right, we should see photos tab + swipeRightAndWait(TAB_VIEW_PAGER_ID); + + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isNotSelected())); + // Verify first item is recent header, we are in photos tab + onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID) + .atPositionOnView(0, R.id.date_header_title)) + .check(matches(withText(R.string.recent))); + } + } + + @Test + public void testPreview_singleSelect_image() throws Exception { + // Bottom sheet assertions are different based on the orientation + setPortraitOrientation(mScenario); + + onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); + + final BottomSheetIdlingResource bottomSheetIdlingResource = + BottomSheetIdlingResource.register(mScenario); + + try { + bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); + onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); + onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); + mScenario.onActivity(activity -> { + assertBottomSheetState(activity, STATE_COLLAPSED); + }); + + // Navigate to preview + longClickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); + + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition(mScenario, + PhotoPickerUiEventLogger.PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + _SPECIAL_FORMAT_NONE, JPEG_IMAGE_MIME_TYPE, IMAGE_1_POSITION); + + try (ViewPager2IdlingResource idlingResource = + ViewPager2IdlingResource.register(mScenario, PREVIEW_VIEW_PAGER_ID)) { + // No dragBar in preview + bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); + onView(withId(DRAG_BAR_ID)).check(matches(not(isDisplayed()))); + // No privacy text in preview + onView(withId(PRIVACY_TEXT_ID)).check(matches(not(isDisplayed()))); + mScenario.onActivity(activity -> { + assertBottomSheetState(activity, STATE_EXPANDED); + }); + + // Verify image is previewed + PreviewFragmentAssertionUtils.assertSingleSelectCommonLayoutMatches(); + onView(withId(R.id.preview_imageView)).check(matches(isDisplayed())); + // Verify no special format icon is previewed + onView(withId(PREVIEW_MOTION_PHOTO_ID)).check(doesNotExist()); + onView(withId(PREVIEW_GIF_ID)).check(doesNotExist()); + // Verify the overflow menu is not shown for PICK_IMAGES intent + assertOverflowMenuNotShown(); + } + // Navigate back to Photo grid + onView(withContentDescription("Navigate up")).perform(click()); + + onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); + onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); + onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); + + bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); + // Shows dragBar and privacy text after we are back to Photos tab + mScenario.onActivity(activity -> { + assertBottomSheetState(activity, STATE_COLLAPSED); + }); + } finally { + IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/espresso/MaxSelectionTest.java b/tests/src/com/android/providers/media/photopicker/espresso/MaxSelectionTest.java index c025cc02a..3d3870c40 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/MaxSelectionTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/MaxSelectionTest.java @@ -17,6 +17,7 @@ package com.android.providers.media.photopicker.espresso; import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; @@ -28,6 +29,9 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotSelected; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemSelected; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.clickItem; +import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.longClickItem; + +import static org.hamcrest.Matchers.not; import android.view.View; @@ -37,10 +41,13 @@ import androidx.test.espresso.IdlingResource; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; +import com.android.providers.media.library.RunOnlyOnPostsubmit; + import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class MaxSelectionTest extends PhotoPickerBaseTest { private static final int MAX_SELECTION_COUNT = 2; @@ -57,10 +64,36 @@ public class MaxSelectionTest extends PhotoPickerBaseTest { clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_CHECK_ID); - // Select second image item thumbnail and verify select icon is selected - clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_2_POSITION, ICON_THUMBNAIL_ID); + // Assert that when the max selection is not yet reached, the select button is visible on + // long click preview of an unselected item (the second image item in this case). + // Then select this item (the second image item) by clicking the select button. + longClickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_2_POSITION, ICON_THUMBNAIL_ID); + onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)) + .check(matches(isDisplayed())) + .perform(click()); + + // Go back to the photos grid + pressBack(); + + // Verify that the select icon is selected for the second image item assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_2_POSITION, ICON_CHECK_ID); + // Assert that when the max selection is reached, the select button is not visible on long + // click preview of an unselected item (the video item in this case). + longClickItem(PICKER_TAB_RECYCLERVIEW_ID, VIDEO_POSITION, ICON_THUMBNAIL_ID); + onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(not(isDisplayed()))); + + // Go back to the photos grid + pressBack(); + + // Assert that the deselect button is always visible on long click preview of a selected + // item (any of the 2 image items in this case), irrespective of the max selection + longClickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); + onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(isDisplayed())); + + // Go back to the photos grid + pressBack(); + // Click Video item thumbnail and verify select icon is not selected. Because we set the // max selection is 2. clickItem(PICKER_TAB_RECYCLERVIEW_ID, VIDEO_POSITION, ICON_THUMBNAIL_ID); diff --git a/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java b/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java index ce0c612ee..1905838d9 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java @@ -22,33 +22,46 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; -import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.core.app.ActivityScenario; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; -import org.junit.Rule; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class MimeTypeFilterTest extends PhotoPickerBaseTest { private static final String IMAGE_MIME_TYPE = "image/*"; + private static final String VIDEO_MIME_TYPE = "video/*"; + public ActivityScenario<PhotoPickerTestActivity> mScenario; + + @Before + public void launchActivity() { + mScenario = + ActivityScenario.launchActivityForResult( + PhotoPickerBaseTest.getSingleSelectMimeTypeFilterIntent(IMAGE_MIME_TYPE)); + } - @Rule - public ActivityScenarioRule<PhotoPickerTestActivity> mRule = new ActivityScenarioRule<>( - PhotoPickerBaseTest.getSingleSelectMimeTypeFilterIntent(IMAGE_MIME_TYPE)); + @After + public void closeActivity() { + mScenario.close(); + } @Test public void testPhotosTabOnlyImageItems() { - onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); // Two image items and one recent date header @@ -90,4 +103,21 @@ public class MimeTypeFilterTest extends PhotoPickerBaseTest { onView(allOf(withId(itemCountId), withParent(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(doesNotExist()); } + + @Test + public void testPickerTabTitleText_forVariousMimeTypeFilters() { + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + + mScenario = ActivityScenario.launchActivityForResult( + PhotoPickerBaseTest.getSingleSelectMimeTypeFilterIntent(VIDEO_MIME_TYPE)); + onView(allOf(withText(PICKER_VIDEOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + + mScenario = ActivityScenario.launchActivityForResult( + PhotoPickerBaseTest.getSingleSelectionIntent()); + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + + } }
\ No newline at end of file diff --git a/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java index ce55a830b..aed2d9669 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java @@ -51,18 +51,17 @@ import androidx.test.espresso.action.ViewActions; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class MultiSelectTest extends PhotoPickerBaseTest { - private static final int TAB_VIEW_PAGER_ID = R.id.picker_tab_viewpager; - private ActivityScenario<PhotoPickerTestActivity> mScenario; @Before @@ -78,11 +77,6 @@ public class MultiSelectTest extends PhotoPickerBaseTest { } @Test - public void testMultiSelectDoesNotShowProfileButton() { - assertProfileButtonNotShown(); - } - - @Test public void testMultiselect_showDragBar() { onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); } @@ -254,7 +248,6 @@ public class MultiSelectTest extends PhotoPickerBaseTest { } @Test - @Ignore("Enable after b/228574741 is fixed") public void testMultiSelectTabSwiping() throws Exception { onView(withId(TAB_LAYOUT_ID)).check(matches(isDisplayed())); @@ -287,7 +280,6 @@ public class MultiSelectTest extends PhotoPickerBaseTest { } @Test - @Ignore("Enable after b/222013536 is fixed") public void testMultiSelectScrollDownToClose() { final BottomSheetIdlingResource bottomSheetIdlingResource = BottomSheetIdlingResource.register(mScenario); @@ -300,15 +292,6 @@ public class MultiSelectTest extends PhotoPickerBaseTest { assertBottomSheetState(activity, STATE_EXPANDED); }); - // Shows dragBar and privacy text after we are back to Photos tab - onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); - onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); - mScenario.onActivity(activity -> { - assertBottomSheetState(activity, STATE_EXPANDED); - }); - - // Swiping down on drag bar or toolbar is not closing the bottom sheet as closing the - // bottomsheet requires a stronger downward swipe. onView(withId(R.id.bottom_sheet)).perform(ViewActions.swipeDown()); } finally { IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); @@ -317,29 +300,4 @@ public class MultiSelectTest extends PhotoPickerBaseTest { assertThat(mScenario.getResult().getResultCode()).isEqualTo( Activity.RESULT_CANCELED); } - - - private void assertProfileButtonNotShown() { - // Partial screen does not show profile button - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); - - // Navigate to Albums tab - onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .perform(click()); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); - - final int cameraStringId = R.string.picker_category_camera; - // Navigate to photos in Camera album - onView(allOf(withText(cameraStringId), - isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).perform(click()); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); - - // Click back button - onView(withContentDescription("Navigate up")).perform(click()); - - // on clicking back button we are back to Album grid - onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .check(matches(isSelected())); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); - } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/NoItemsTest.java b/tests/src/com/android/providers/media/photopicker/espresso/NoItemsTest.java index 969707756..277f1dd16 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/NoItemsTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/NoItemsTest.java @@ -22,7 +22,6 @@ import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; @@ -34,12 +33,13 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import android.provider.MediaStore; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; -import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class NoItemsTest extends PhotoPickerBaseTest { diff --git a/tests/src/com/android/providers/media/photopicker/espresso/OrientationUtils.java b/tests/src/com/android/providers/media/photopicker/espresso/OrientationUtils.java index 9d0be4702..2cc03f6de 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/OrientationUtils.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/OrientationUtils.java @@ -28,16 +28,18 @@ import static com.google.common.truth.Truth.assertThat; import androidx.test.core.app.ActivityScenario; class OrientationUtils { - public static void setLandscapeOrientation(ActivityScenario<PhotoPickerTestActivity> scenario) { + public static <T extends PhotoPickerTestActivity> void setLandscapeOrientation( + ActivityScenario<T> scenario) { changeOrientation(scenario, SCREEN_ORIENTATION_LANDSCAPE, ORIENTATION_LANDSCAPE); } - public static void setPortraitOrientation(ActivityScenario<PhotoPickerTestActivity> scenario) { + public static <T extends PhotoPickerTestActivity> void setPortraitOrientation( + ActivityScenario<T> scenario) { changeOrientation(scenario, SCREEN_ORIENTATION_PORTRAIT, ORIENTATION_PORTRAIT); } - private static void changeOrientation( - ActivityScenario<PhotoPickerTestActivity> scenario, + private static <T extends PhotoPickerTestActivity> void changeOrientation( + ActivityScenario<T> scenario, int screenOrientation, int configOrientation) { scenario.onActivity( diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerAccessibilityDisabledTestActivity.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerAccessibilityDisabledTestActivity.java new file mode 100644 index 000000000..26d722ef9 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerAccessibilityDisabledTestActivity.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker.espresso; + +/** + * In espresso tests, the default accessibility mode, evaluated by + * {@link android.view.accessibility.AccessibilityManager#isEnabled()}, is enabled. + * + * {@link PhotoPickerAccessibilityDisabledTestActivity} is used to cover the code that requires the + * accessibility to be disabled. + * + * This {@link android.app.Activity} is launched using the {@link android.content.Intent} + * {@link android.content.Intent#ACTION_MAIN}. + */ +public class PhotoPickerAccessibilityDisabledTestActivity extends PhotoPickerTestActivity { + @Override + protected boolean isAccessibilityEnabled() { + return false; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java index b76acbb28..3d0bdc1eb 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java @@ -25,18 +25,16 @@ import static androidx.test.espresso.matcher.ViewMatchers.isNotSelected; import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static com.android.providers.media.PickerUriResolver.REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI; import static com.android.providers.media.photopicker.espresso.BottomSheetTestUtils.assertBottomSheetState; -import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.customSwipeDownPartialScreen; -import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.swipeLeftAndWait; -import static com.android.providers.media.photopicker.espresso.CustomSwipeAction.swipeRightAndWait; import static com.android.providers.media.photopicker.espresso.OrientationUtils.setLandscapeOrientation; -import static com.android.providers.media.photopicker.espresso.OrientationUtils.setPortraitOrientation; import static com.android.providers.media.photopicker.espresso.OverflowMenuUtils.assertOverflowMenuNotShown; import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView; -import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED; import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED; import static com.google.common.truth.Truth.assertThat; @@ -44,6 +42,7 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.not; import android.app.Activity; +import android.content.ContentResolver; import androidx.test.InstrumentationRegistry; import androidx.test.core.app.ActivityScenario; @@ -53,6 +52,7 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import androidx.viewpager2.widget.ViewPager2; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import org.junit.After; import org.junit.Before; @@ -60,11 +60,12 @@ import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.concurrent.TimeUnit; + +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class PhotoPickerActivityTest extends PhotoPickerBaseTest { - private static final int TAB_VIEW_PAGER_ID = R.id.picker_tab_viewpager; - public ActivityScenario<PhotoPickerTestActivity> mScenario; @Before @@ -88,7 +89,8 @@ public class PhotoPickerActivityTest extends PhotoPickerBaseTest { onView(withId(R.id.fragment_container)).check(matches(isDisplayed())); onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); - // Partial screen does not show profile button + // Assuming by default, the tests run without a managed user + // Single user mode does not show profile button onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); onView(withId(android.R.id.empty)).check(matches(not(isDisplayed()))); @@ -100,75 +102,29 @@ public class PhotoPickerActivityTest extends PhotoPickerBaseTest { } @Test - public void testDoesNotShowProfileButton_partialScreen() { - assertProfileButtonNotShown(); - } - - @Test - @Ignore("Enable after b/222013536 is fixed") - public void testDoesNotShowProfileButton_fullScreen() { - // Bottomsheet assertions are different for landscape mode - setPortraitOrientation(mScenario); + public void testProfileButtonHiddenInSingleUserMode() { + // Assuming that the test runs without a managed user - // Partial screen does not show profile button + // Single user mode does not show profile button in the main grid onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); - BottomSheetTestUtils.swipeUp(mScenario); - - assertProfileButtonNotShown(); - } - - @Test - @Ignore("Enable after b/222013536 is fixed") - public void testBottomSheetState() { - // Bottom sheet assertions are different for landscape mode - setPortraitOrientation(mScenario); - - // Register bottom sheet idling resource so that we don't read bottom sheet state when - // in between changing states - final BottomSheetIdlingResource bottomSheetIdlingResource = - BottomSheetIdlingResource.register(mScenario); - - try { - // Single select PhotoPicker is launched in partial screen mode - bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); - onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); - onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); - mScenario.onActivity( - activity -> { - assertBottomSheetState(activity, STATE_COLLAPSED); - }); - - // Swipe up and check that the PhotoPicker is in full screen mode - bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); - onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeUp()); - mScenario.onActivity( - activity -> { - assertBottomSheetState(activity, STATE_EXPANDED); - }); + onView(withId(TAB_LAYOUT_ID)).check(matches(isDisplayed())); - // Swipe down and check that the PhotoPicker is in partial screen mode - bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); - onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeDown()); - mScenario.onActivity( - activity -> { - assertBottomSheetState(activity, STATE_COLLAPSED); - }); + // On clicking albums tab item, we should see albums tab + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .perform(click()); + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isNotSelected())); - // Swiping down on drag bar is not strong enough as closing the bottomsheet requires a - // stronger downward swipe using espresso. - // Simply swiping down on R.id.bottom_sheet throws an error from espresso, as the view - // is only 60% visible, but downward swipe is only successful on an element which is 90% - // visible. - onView(withId(R.id.bottom_sheet)).perform(customSwipeDownPartialScreen()); - } finally { - IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); - } - assertThat(mScenario.getResult().getResultCode()).isEqualTo(Activity.RESULT_CANCELED); + // Single user mode does not show profile button in the albums grid + onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); } @Test - @Ignore("Enable after b/222013536 is fixed") + @Ignore("b/313489524") + // TODO(b/313489524): Fix flaky orientation change in the photo picker espresso tests public void testBottomSheetStateInLandscapeMode() { // Bottom sheet assertions are different for landscape mode setLandscapeOrientation(mScenario); @@ -246,89 +202,37 @@ public class PhotoPickerActivityTest extends PhotoPickerBaseTest { } @Test - @Ignore("Enable after b/222013536 is fixed") - public void testTabSwiping() throws Exception { - onView(withId(TAB_LAYOUT_ID)).check(matches(isDisplayed())); - - // If we want to swipe the viewPager2 of tabContainerFragment in Espresso tests, at least 90 - // percent of the view's area is displayed to the user. Swipe up the bottom Sheet to make - // sure it is in full Screen mode. - // Register bottom sheet idling resource so that we don't read bottom sheet state when - // in between changing states - final BottomSheetIdlingResource bottomSheetIdlingResource = - BottomSheetIdlingResource.register(mScenario); - - try { - - // When accessibility is enabled, we always launch the photo picker in full screen mode. - // Accessibility is enabled in Espresso test, so we can't check the COLLAPSED state. - // // Single select PhotoPicker is launched in partial screen mode - // bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); - // mScenario.onActivity(activity -> { - // assertBottomSheetState(activity, STATE_COLLAPSED); - // }); - - // Swipe up and check that the PhotoPicker is in full screen mode. - // onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); - // onView(withId(PRIVACY_TEXT_ID)).perform(ViewActions.swipeUp()); - bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); - mScenario.onActivity( - activity -> { - assertBottomSheetState(activity, STATE_EXPANDED); - }); - } finally { - IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); - } - - try (ViewPager2IdlingResource idlingResource = - ViewPager2IdlingResource.register(mScenario, TAB_VIEW_PAGER_ID)) { - // Swipe left, we should see albums tab - swipeLeftAndWait(TAB_VIEW_PAGER_ID); - - onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .check(matches(isSelected())); - onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .check(matches(isNotSelected())); - // Verify Camera album is shown, we are in albums tab - onView(allOf(withText(R.string.picker_category_camera), - isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check( - matches(isDisplayed())); - - // Swipe right, we should see photos tab - swipeRightAndWait(TAB_VIEW_PAGER_ID); - - onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .check(matches(isSelected())); - onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) - .check(matches(isNotSelected())); - // Verify first item is recent header, we are in photos tab - onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID) - .atPositionOnView(0, R.id.date_header_title)) - .check(matches(withText(R.string.recent))); - } - } - - private void assertProfileButtonNotShown() { - // Partial screen does not show profile button - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); + public void testResetOnCloudProviderChange() throws InterruptedException { + // Enable cloud media feature for the activity through the test config store + mScenario.onActivity( + activity -> + activity.getConfigStore() + .enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + "com.hooli.super.awesome.cloud.provider")); - // Navigate to Albums tab + // Switch to the albums tab onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) .perform(click()); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .check(matches(isSelected())); + // Navigate to the photos in the Camera album final int cameraStringId = R.string.picker_category_camera; - // Navigate to photos in Camera album - onView(allOf(withText(cameraStringId), - isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).perform(click()); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); + onView(allOf(withText(cameraStringId), isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))) + .perform(click()); + onView(allOf(withText(cameraStringId), withParent(withId(R.id.toolbar)))) + .check(matches(isDisplayed())); - // Click back button - onView(withContentDescription("Navigate up")).perform(click()); + // Notify refresh ui + final ContentResolver contentResolver = + getInstrumentation().getTargetContext().getContentResolver(); + contentResolver.notifyChange( + REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, /* observer= */ null); - // on clicking back button we are back to Album grid - onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + TimeUnit.MILLISECONDS.sleep(/* timeout= */ 100); + + // Verify activity reset to the initial launch state (Photos tab) + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) .check(matches(isSelected())); - onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java index 3569efdec..f0b6b0343 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java @@ -29,12 +29,15 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Environment; +import android.os.Process; import android.provider.MediaStore; import android.system.ErrnoException; import android.system.Os; import androidx.core.util.Supplier; +import androidx.lifecycle.MutableLiveData; import androidx.test.InstrumentationRegistry; +import androidx.work.testing.WorkManagerTestInitHelper; import com.android.providers.media.IsolatedContext; import com.android.providers.media.R; @@ -54,9 +57,11 @@ import java.util.concurrent.TimeoutException; public class PhotoPickerBaseTest { protected static final int PICKER_TAB_RECYCLERVIEW_ID = R.id.picker_tab_recyclerview; + protected static final int TAB_VIEW_PAGER_ID = R.id.picker_tab_viewpager; protected static final int TAB_LAYOUT_ID = R.id.tab_layout; protected static final int PICKER_PHOTOS_STRING_ID = R.string.picker_photos; protected static final int PICKER_ALBUMS_STRING_ID = R.string.picker_albums; + protected static final int PICKER_VIDEOS_STRING_ID = R.string.picker_videos; protected static final int PREVIEW_VIEW_PAGER_ID = R.id.preview_viewPager; protected static final int ICON_CHECK_ID = R.id.icon_check; protected static final int ICON_THUMBNAIL_ID = R.id.icon_thumbnail; @@ -67,6 +72,12 @@ public class PhotoPickerBaseTest { protected static final int PREVIEW_MOTION_PHOTO_ID = R.id.preview_motion_photo; protected static final int PREVIEW_ADD_OR_SELECT_BUTTON_ID = R.id.preview_add_or_select_button; protected static final int PRIVACY_TEXT_ID = R.id.privacy_text; + protected static final String GIF_IMAGE_MIME_TYPE = "image/gif"; + protected static final String ANIMATED_WEBP_MIME_TYPE = "image/webp"; + protected static final String JPEG_IMAGE_MIME_TYPE = "image/jpeg"; + protected static final String MP4_VIDEO_MIME_TYPE = "video/mp4"; + + protected static final String MANAGED_SELECTION_ENABLED_EXTRA = "MANAGED_SELECTION_ENABLE"; protected static final int DIMEN_PREVIEW_ADD_OR_SELECT_WIDTH = R.dimen.preview_add_or_select_width; @@ -112,11 +123,22 @@ public class PhotoPickerBaseTest { sUserSelectImagesForAppIntent = new Intent(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP); sUserSelectImagesForAppIntent.addCategory(Intent.CATEGORY_FRAMEWORK_INSTRUMENTATION_TEST); Bundle extras = new Bundle(); - extras.putInt(Intent.EXTRA_UID, 1234); + extras.putInt(Intent.EXTRA_UID, Process.myUid()); sUserSelectImagesForAppIntent.putExtras(extras); } - private static final File IMAGE_1_FILE = new File(Environment.getExternalStorageDirectory(), + private static final Intent sPickerChoiceManagedSelectionIntent; + static { + sPickerChoiceManagedSelectionIntent = new Intent( + MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP); + sPickerChoiceManagedSelectionIntent.addCategory( + Intent.CATEGORY_FRAMEWORK_INSTRUMENTATION_TEST); + Bundle extras = new Bundle(); + extras.putInt(Intent.EXTRA_UID, Process.myUid()); + extras.putBoolean(MANAGED_SELECTION_ENABLED_EXTRA, true); + sPickerChoiceManagedSelectionIntent.putExtras(extras); + } + public static final File IMAGE_1_FILE = new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DCIM + "/Camera" + "/image_" + System.currentTimeMillis() + ".jpeg"); private static final File IMAGE_2_FILE = new File(Environment.getExternalStorageDirectory(), @@ -148,6 +170,9 @@ public class PhotoPickerBaseTest { return sUserSelectImagesForAppIntent; } + public static Intent getPickerChoiceManagedSelectionIntent() { + return sPickerChoiceManagedSelectionIntent; + } public static Intent getMultiSelectionIntent(int max) { final Intent intent = new Intent(sMultiSelectionIntent); Bundle extras = new Bundle(); @@ -182,6 +207,8 @@ public class PhotoPickerBaseTest { sUserIdManager = mock(UserIdManager.class); when(sUserIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); + WorkManagerTestInitHelper.initializeTestWorkManager(sIsolatedContext); + createFiles(); } @@ -283,6 +310,7 @@ public class PhotoPickerBaseTest { updateIsManagedUserSelected(/* isManagedUserSelected */ true); return null; }).when(sUserIdManager).setManagedAsCurrentUserProfile(); + when(sUserIdManager.getCrossProfileAllowed()).thenReturn(new MutableLiveData<>(true)); } /** @@ -307,6 +335,7 @@ public class PhotoPickerBaseTest { when(sUserIdManager.isWorkProfileOff()).thenReturn(false); when(sUserIdManager.isCrossProfileAllowed()).thenReturn(false); when(sUserIdManager.isManagedUserSelected()).thenReturn(true); + when(sUserIdManager.getCrossProfileAllowed()).thenReturn(new MutableLiveData<>(false)); } private static void updateIsManagedUserSelected(boolean isManagedUserSelected) { diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java index 062bf3a16..502623520 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java @@ -16,17 +16,52 @@ package com.android.providers.media.photopicker.espresso; +import static com.android.providers.media.photopicker.espresso.PhotoPickerBaseTest.MANAGED_SELECTION_ENABLED_EXTRA; + +import static org.mockito.Mockito.RETURNS_SMART_NULLS; +import static org.mockito.Mockito.mock; + +import androidx.annotation.NonNull; + +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.UiEventLogger; +import com.android.providers.media.TestConfigStore; import com.android.providers.media.photopicker.PhotoPickerActivity; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; import com.android.providers.media.photopicker.viewmodel.PickerViewModel; public class PhotoPickerTestActivity extends PhotoPickerActivity { + private final TestConfigStore mConfigStore = new TestConfigStore(); + private final UiEventLogger mLogger = mock(UiEventLogger.class, RETURNS_SMART_NULLS); + private InstanceId mInstanceId; + @Override + @NonNull protected PickerViewModel getOrCreateViewModel() { - PickerViewModel pickerViewModel = super.getOrCreateViewModel(); + final PickerViewModel pickerViewModel = super.getOrCreateViewModel(); + if (getIntent().getExtras() != null && getIntent().getExtras().getBoolean( + MANAGED_SELECTION_ENABLED_EXTRA)) { + mConfigStore.enablePickerChoiceManagedSelectionEnabled(); + } + pickerViewModel.setConfigStore(mConfigStore); pickerViewModel.setItemsProvider(new ItemsProvider( PhotoPickerBaseTest.getIsolatedContext())); pickerViewModel.setUserIdManager(PhotoPickerBaseTest.getMockUserIdManager()); + pickerViewModel.setLogger(new PhotoPickerUiEventLogger(mLogger)); + mInstanceId = pickerViewModel.getInstanceId(); return pickerViewModel; } + + TestConfigStore getConfigStore() { + return mConfigStore; + } + + UiEventLogger getLogger() { + return mLogger; + } + + InstanceId getInstanceId() { + return mInstanceId; + } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerUserSelectActivityTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerUserSelectActivityTest.java index 3d1a13d51..8c39da5e7 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerUserSelectActivityTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerUserSelectActivityTest.java @@ -16,11 +16,13 @@ package com.android.providers.media.photopicker.espresso; +import static androidx.test.InstrumentationRegistry.getTargetContext; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isNotSelected; import static androidx.test.espresso.matcher.ViewMatchers.isSelected; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; @@ -31,25 +33,40 @@ import static com.android.providers.media.photopicker.espresso.RecyclerViewTestU import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_BANNER; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static junit.framework.Assert.fail; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.not; import android.app.Activity; +import android.content.ContentUris; import android.content.Intent; +import android.net.Uri; import android.provider.MediaStore; +import androidx.lifecycle.ViewModelProvider; import androidx.test.InstrumentationRegistry; import androidx.test.core.app.ActivityScenario; import androidx.test.filters.SdkSuppress; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.DataLoaderThread; +import com.android.providers.media.photopicker.data.Selection; +import com.android.providers.media.photopicker.viewmodel.PickerViewModel; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +@RunOnlyOnPostsubmit @SdkSuppress(minSdkVersion = 34, codeName = "UpsideDownCake") @RunWith(AndroidJUnit4ClassRunner.class) public class PhotoPickerUserSelectActivityTest extends PhotoPickerBaseTest { @@ -85,7 +102,7 @@ public class PhotoPickerUserSelectActivityTest extends PhotoPickerBaseTest { @Test public void testActivityProfileButtonNotShown() { launchValidActivity(); - // Partial screen does not show profile button + // User select mode does not show profile button onView(withId(R.id.profile_button)).check(matches(not(isDisplayed()))); // Navigate to Albums tab @@ -130,6 +147,34 @@ public class PhotoPickerUserSelectActivityTest extends PhotoPickerBaseTest { } @Test + public void testAddButtonIsShowsAllowNone() { + launchValidActivityWithManagedSelectionEnabled(); + final int bottomBarId = R.id.picker_bottom_bar; + final int viewSelectedId = R.id.button_view_selected; + final int addButtonId = R.id.button_add; + + // Default view, no item selected. + onView(withId(bottomBarId)).check(matches(isDisplayed())); + onView(withId(viewSelectedId)).check(matches(not(isDisplayed()))); + onView(withId(addButtonId)).check(matches(isDisplayed())); + // verify that 'Allow none' is displayed in this case. + onView(withId(addButtonId)).check( + matches(withText(R.string.picker_add_button_allow_none_option))); + + clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); + + onView(withId(bottomBarId)).check(matches(isDisplayed())); + onView(withId(viewSelectedId)).check(matches(isDisplayed())); + + onView(withId(addButtonId)).check(matches(withText("Allow (1)"))); + onView(withId(addButtonId)).check(matches(isDisplayed())); + + + onView(withId(VIEW_SELECTED_BUTTON_ID)).perform(click()); + onView(withId(addButtonId)).check(matches(withText("Allow (1)"))); + } + + @Test public void testNoCloudSettingsAndBanners() { launchValidActivity(); @@ -142,6 +187,134 @@ public class PhotoPickerUserSelectActivityTest extends PhotoPickerBaseTest { } @Test + public void testPreview_deselectAll_showAllowNone() throws Exception { + launchValidActivityWithManagedSelectionEnabled(); + onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); + + // Select first and second image + clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); + // Navigate to preview + onView(withId(VIEW_SELECTED_BUTTON_ID)).perform(click()); + try (ViewPager2IdlingResource idlingResource = + ViewPager2IdlingResource.register(mScenario, PREVIEW_VIEW_PAGER_ID)) { + final int previewAddButtonId = R.id.preview_add_button; + final int previewSelectButtonId = R.id.preview_selected_check_button; + final String selectedString = + getTargetContext().getResources().getString(R.string.selected); + // Verify that, initially, we show "selected" check button + onView(withId(previewSelectButtonId)).check(matches(isSelected())); + onView(withId(previewSelectButtonId)).check(matches(withText(selectedString))); + // Verify that the text in Add button matches "Allow (1)" + onView(withId(previewAddButtonId)) + .check(matches(withText("Allow (1)"))); + + // Deselect item in preview + onView(withId(previewSelectButtonId)).perform(click()); + onView(withId(previewSelectButtonId)).check(matches(isNotSelected())); + onView(withId(previewSelectButtonId)).check(matches(withText(R.string.deselected))); + // Verify that the text in Add button now changes to "Allow none" + onView(withId(previewAddButtonId)) + .check(matches(withText("Allow none"))); + // Verify that we have 0 items in selected items + mScenario.onActivity(activity -> { + Selection selection = + new ViewModelProvider(activity).get(PickerViewModel.class).getSelection(); + assertThat(selection.getSelectedItemCount().getValue()).isEqualTo(0); + }); + + // Select the item again + onView(withId(previewSelectButtonId)).perform(click()); + onView(withId(previewSelectButtonId)).check(matches(isSelected())); + onView(withId(previewSelectButtonId)).check(matches(withText(selectedString))); + // Verify that the text in Add button now changes back to "Allow (1)" + onView(withId(previewAddButtonId)) + .check(matches(withText("Allow (1)"))); + // Verify that we have 1 item in selected items + mScenario.onActivity(activity -> { + Selection selection = + new ViewModelProvider(activity).get(PickerViewModel.class).getSelection(); + assertThat(selection.getSelectedItemCount().getValue()).isEqualTo(1); + }); + } + } + + @Test + public void testPreview_showsOnlyAlreadyLoadedGrantItems() throws Exception { + launchValidActivityWithManagedSelectionEnabled(); + onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); + + final Uri uri = MediaStore.scanFile(getIsolatedContext().getContentResolver(), + IMAGE_1_FILE); + MediaStore.waitForIdle(getIsolatedContext().getContentResolver()); + mScenario.onActivity(activity -> { + // Add an item id to the pre-granted set, so that when preview fragment gets opened up + // there is something to load as a remaining item. + Selection selection = + new ViewModelProvider(activity).get(PickerViewModel.class).getSelection(); + selection.setTotalNumberOfPreGrantedItems(1); + selection.setPreGrantedItemSet(Set.of(String.valueOf(ContentUris.parseId(uri)))); + + // Verify that we don't have anything to preview + selection.prepareSelectedItemsForPreviewAll(); + assertWithMessage("Expected preview-able item list to be empty") + .that(selection.getSelectedItemsForPreview()).isEmpty(); + }); + + // Block the DataLoader thread by posting a conditional wait. This will block fetching of + // pregranted items in preview + final CountDownLatch latch = new CountDownLatch(1); + DataLoaderThread.waitForIdle(); + DataLoaderThread.getHandler().postDelayed(() -> { + // Wait for 5 seconds if we don't receive a countdown + try { + assertWithMessage("Expected the test to send countdown before 5s") + .that(latch.await(5, TimeUnit.SECONDS)).isTrue(); + } catch (InterruptedException e) { + fail("Unexpected excepetion : " + e.getMessage()); + } + }, DataLoaderThread.TOKEN, 0); + + // Navigate to preview + onView(withId(VIEW_SELECTED_BUTTON_ID)).perform(click()); + + // Verify that UI shows no selected / deselected button + try (ViewPager2IdlingResource idlingResource = + ViewPager2IdlingResource.register(mScenario, PREVIEW_VIEW_PAGER_ID)) { + final int previewAddButtonId = R.id.preview_add_button; + final int previewSelectButtonId = R.id.preview_selected_check_button; + // Verify that, initially, we show "selected" check button + onView(withId(previewSelectButtonId)).check(matches(not(isDisplayed()))); + onView(withId(previewAddButtonId)).check(matches(isDisplayed())); + // Verify that the text in Add button matches "Allow (1)" + onView(withId(previewAddButtonId)).check(matches(withText("Allow (1)"))); + } + + // Free DataLoaderThread so that it can load pregranted items + latch.countDown(); + DataLoaderThread.waitForIdle(); + + // Verify that UI now shows selected button + try (ViewPager2IdlingResource idlingResource = + ViewPager2IdlingResource.register(mScenario, PREVIEW_VIEW_PAGER_ID)) { + final int previewAddButtonId = R.id.preview_add_button; + final int previewSelectButtonId = R.id.preview_selected_check_button; + final String selectedString = + getTargetContext().getResources().getString(R.string.selected); + // Verify that, initially, we show "selected" check button + onView(withId(previewSelectButtonId)).check(matches(isSelected())); + onView(withId(previewSelectButtonId)).check(matches(withText(selectedString))); + // Verify that the text in Add button matches "Allow (1)" + onView(withId(previewAddButtonId)) + .check(matches(withText("Allow (1)"))); + mScenario.onActivity(activity -> { + Selection selection = + new ViewModelProvider(activity).get(PickerViewModel.class).getSelection(); + assertThat(selection.getSelectedItemCount().getValue()).isEqualTo(1); + }); + } + } + + @Test public void testUserSelectCorrectHeaderTextIsShown() { launchValidActivity(); onView(withText(R.string.picker_header_permissions)).check(matches(isDisplayed())); @@ -153,4 +326,10 @@ public class PhotoPickerUserSelectActivityTest extends PhotoPickerBaseTest { ActivityScenario.launchActivityForResult( PhotoPickerBaseTest.getUserSelectImagesForAppIntent()); } + + /** Test helper to launch a valid test activity. */ + private void launchValidActivityWithManagedSelectionEnabled() { + mScenario = ActivityScenario.launchActivityForResult( + PhotoPickerBaseTest.getPickerChoiceManagedSelectionIntent()); + } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java index b59ceb595..265b8719c 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java @@ -40,12 +40,15 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; import com.android.providers.media.photopicker.util.DateTimeUtils; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class PhotosTabTest extends PhotoPickerBaseTest { private static final int ICON_GIF_ID = R.id.icon_gif; @@ -64,6 +67,10 @@ public class PhotosTabTest extends PhotoPickerBaseTest { // check the count of items onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(new RecyclerViewItemCountAssertion(4)); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_PHOTOS, /* countOfMediaItems */ 3); + // Verify first item is recent header onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID) .atPositionOnView(0, R.id.date_header_title)) @@ -137,6 +144,10 @@ public class PhotosTabTest extends PhotoPickerBaseTest { // Verify that drag bar is shown onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition(mRule, + PhotoPickerEvent.PHOTO_PICKER_UI_LOADED_ALBUM_CONTENTS, /* countOfMediaItems */ 1); + final int dateHeaderTitleId = R.id.date_header_title; final int recentHeaderPosition = 0; // Verify that first item is not a recent header @@ -171,4 +182,19 @@ public class PhotosTabTest extends PhotoPickerBaseTest { onView(allOf(withText(cameraStringId), isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(matches(isDisplayed())); } + + @Test + public void testSwitchToPhotosGrid() { + // Goto Albums page + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .perform(click()); + + // Goto Photos page + onView(allOf(withText(PICKER_PHOTOS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .perform(click()); + + // Verify log + UiEventLoggerTestUtils.verifyLogWithInstanceId( + mRule, PhotoPickerEvent.PHOTO_PICKER_TAB_PHOTOS_OPEN); + } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PreviewFragmentAssertionUtils.java b/tests/src/com/android/providers/media/photopicker/espresso/PreviewFragmentAssertionUtils.java new file mode 100644 index 000000000..fefc641eb --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/espresso/PreviewFragmentAssertionUtils.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker.espresso; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static org.hamcrest.Matchers.not; + +import com.android.providers.media.R; + +class PreviewFragmentAssertionUtils { + private static final int PREVIEW_ADD_OR_SELECT_BUTTON_ID = R.id.preview_add_or_select_button; + + static void assertSingleSelectCommonLayoutMatches() { + onView(withId(R.id.preview_viewPager)).check(matches(isDisplayed())); + onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(isDisplayed())); + // Verify that the text in Add button + onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(withText(R.string.add))); + + onView(withId(R.id.preview_selected_check_button)).check(matches(not(isDisplayed()))); + onView(withId(R.id.preview_add_button)).check(matches(not(isDisplayed()))); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectLongPressTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectLongPressTest.java index 49f85629b..e8d89a7c7 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectLongPressTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectLongPressTest.java @@ -41,6 +41,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.photopicker.data.Selection; import com.android.providers.media.photopicker.viewmodel.PickerViewModel; @@ -48,6 +49,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class PreviewMultiSelectLongPressTest extends PhotoPickerBaseTest { private static final int ICON_THUMBNAIL_ID = R.id.icon_thumbnail; diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectTest.java index e9aa40d5d..c6befda48 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectTest.java @@ -53,7 +53,9 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import androidx.viewpager2.widget.ViewPager2; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import com.android.providers.media.photopicker.data.Selection; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; import com.android.providers.media.photopicker.viewmodel.PickerViewModel; import org.hamcrest.Description; @@ -64,6 +66,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class PreviewMultiSelectTest extends PhotoPickerBaseTest { private static final int VIDEO_PREVIEW_THUMBNAIL_ID = R.id.preview_video_image; @@ -80,8 +83,16 @@ public class PreviewMultiSelectTest extends PhotoPickerBaseTest { // Select two items and Navigate to preview clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_MAIN_GRID, IMAGE_1_POSITION); + clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_2_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_MAIN_GRID, IMAGE_2_POSITION); + onView(withId(VIEW_SELECTED_BUTTON_ID)).perform(click()); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition(mRule, + PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ALL_SELECTED, /* selectedItemCount */ 2); try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { @@ -353,6 +364,9 @@ public class PreviewMultiSelectTest extends PhotoPickerBaseTest { clickItem(PICKER_TAB_RECYCLERVIEW_ID, 1, ICON_THUMBNAIL_ID); assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, /* position */ 1, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_SELECTED_ITEM_ALBUM, /* position= */ 1); + // Navigate to preview onView(withId(VIEW_SELECTED_BUTTON_ID)).perform(click()); diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PreviewSingleSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PreviewSingleSelectTest.java index 241d84295..3de3575a4 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PreviewSingleSelectTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PreviewSingleSelectTest.java @@ -16,6 +16,8 @@ package com.android.providers.media.photopicker.espresso; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; @@ -27,13 +29,11 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static com.android.providers.media.photopicker.espresso.BottomSheetTestUtils.assertBottomSheetState; import static com.android.providers.media.photopicker.espresso.OrientationUtils.setLandscapeOrientation; import static com.android.providers.media.photopicker.espresso.OrientationUtils.setPortraitOrientation; import static com.android.providers.media.photopicker.espresso.OverflowMenuUtils.assertOverflowMenuNotShown; import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.longClickItem; -import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED; import static com.google.common.truth.Truth.assertThat; import static org.hamcrest.Matchers.allOf; @@ -45,16 +45,18 @@ import android.graphics.drawable.Drawable; import android.view.View; import androidx.appcompat.widget.Toolbar; -import androidx.test.espresso.IdlingRegistry; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class PreviewSingleSelectTest extends PhotoPickerBaseTest { @@ -63,79 +65,19 @@ public class PreviewSingleSelectTest extends PhotoPickerBaseTest { = new ActivityScenarioRule<>(PhotoPickerBaseTest.getSingleSelectionIntent()); @Test - public void testPreview_singleSelect_image() throws Exception { - onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); - - // Bottomsheet assertions are different for landscape mode - setPortraitOrientation(mRule.getScenario()); - - final BottomSheetIdlingResource bottomSheetIdlingResource = - BottomSheetIdlingResource.register(mRule.getScenario()); - - try { - // TODO(b/226318844): When accessibility is enabled, we always launch the photo picker - // in full screen mode. Accessibility is enabled in Espresso test, we can't check the - // COLLAPSED state. -// bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); -// onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); -// onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); -// mRule.getScenario().onActivity(activity -> { -// assertBottomSheetState(activity, STATE_COLLAPSED); -// }); - - // Navigate to preview - longClickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); - - try (ViewPager2IdlingResource idlingResource = - ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { - // No dragBar in preview - bottomSheetIdlingResource.setExpectedState(STATE_EXPANDED); - onView(withId(DRAG_BAR_ID)).check(matches(not(isDisplayed()))); - // No privacy text in preview - onView(withId(PRIVACY_TEXT_ID)).check(matches(not(isDisplayed()))); - mRule.getScenario().onActivity(activity -> { - assertBottomSheetState(activity, STATE_EXPANDED); - }); - - // Verify image is previewed - assertSingleSelectCommonLayoutMatches(); - onView(withId(R.id.preview_imageView)).check(matches(isDisplayed())); - // Verify no special format icon is previewed - onView(withId(PREVIEW_MOTION_PHOTO_ID)).check(doesNotExist()); - onView(withId(PREVIEW_GIF_ID)).check(doesNotExist()); - // Verify the overflow menu is not shown for PICK_IMAGES intent - assertOverflowMenuNotShown(); - } - // Navigate back to Photo grid - onView(withContentDescription("Navigate up")).perform(click()); - - onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); - onView(withId(DRAG_BAR_ID)).check(matches(isDisplayed())); - onView(withId(PRIVACY_TEXT_ID)).check(matches(isDisplayed())); - - // TODO(b/226318844): When accessibility is enabled, we always launch the photo picker - // in full screen mode. Accessibility is enabled in Espresso test, we can't check the - // COLLAPSED state. -// bottomSheetIdlingResource.setExpectedState(STATE_COLLAPSED); -// // Shows dragBar and privacy text after we are back to Photos tab -// mRule.getScenario().onActivity(activity -> { -// assertBottomSheetState(activity, STATE_COLLAPSED); -// }); - } finally { - IdlingRegistry.getInstance().unregister(bottomSheetIdlingResource); - } - } - - @Test public void testPreview_singleSelect_video() throws Exception { onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); // Navigate to preview longClickItem(PICKER_TAB_RECYCLERVIEW_ID, VIDEO_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + _SPECIAL_FORMAT_NONE, MP4_VIDEO_MIME_TYPE, VIDEO_POSITION); + try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { - assertSingleSelectCommonLayoutMatches(); + PreviewFragmentAssertionUtils.assertSingleSelectCommonLayoutMatches(); // Verify thumbnail view is displayed onView(withId(R.id.preview_video_image)).check(matches(isDisplayed())); // TODO (b/232792753): Assert video player visibility using custom IdlingResource @@ -169,7 +111,7 @@ public class PreviewSingleSelectTest extends PhotoPickerBaseTest { try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { // Verify image is previewed - assertSingleSelectCommonLayoutMatches(); + PreviewFragmentAssertionUtils.assertSingleSelectCommonLayoutMatches(); onView(withId(R.id.preview_imageView)).check(matches(isDisplayed())); } @@ -250,14 +192,4 @@ public class PreviewSingleSelectTest extends PhotoPickerBaseTest { assertThat(bottomBarDrawable).isInstanceOf(ColorDrawable.class); assertThat(((ColorDrawable) bottomBarDrawable).getColor()).isEqualTo(expectedColor); } - - private void assertSingleSelectCommonLayoutMatches() { - onView(withId(R.id.preview_viewPager)).check(matches(isDisplayed())); - onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(isDisplayed())); - // Verify that the text in Add button - onView(withId(PREVIEW_ADD_OR_SELECT_BUTTON_ID)).check(matches(withText(R.string.add))); - - onView(withId(R.id.preview_selected_check_button)).check(matches(not(isDisplayed()))); - onView(withId(R.id.preview_add_button)).check(matches(not(isDisplayed()))); - } } diff --git a/tests/src/com/android/providers/media/photopicker/espresso/ProgressBarTest.java b/tests/src/com/android/providers/media/photopicker/espresso/ProgressBarTest.java new file mode 100644 index 000000000..8db81498f --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/espresso/ProgressBarTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker.espresso; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.hamcrest.Matchers.allOf; + +import android.support.test.uiautomator.UiDevice; +import android.support.test.uiautomator.UiObject; +import android.support.test.uiautomator.UiSelector; +import android.text.format.DateUtils; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; + +import com.android.providers.media.library.RunOnlyOnPostsubmit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunOnlyOnPostsubmit +@RunWith(AndroidJUnit4ClassRunner.class) +public class ProgressBarTest extends PhotoPickerBaseTest { + public ActivityScenario<PhotoPickerTestActivity> mScenario; + protected static final UiDevice sDevice = UiDevice.getInstance(getInstrumentation()); + + @Before + public void setup() { + startPhotoPickerActivityAndEnableCloudFlag(); + } + + @Test + public void test_progressBarAlbumsTab_isNotVisible() { + + // Navigate to albums tab. + onView(allOf(withText(PICKER_ALBUMS_STRING_ID), isDescendantOfA(withId(TAB_LAYOUT_ID)))) + .perform(click()); + + // Verify that the progress bar and loading text is not visible. + assertProgressBarAndLoadingTextDoesNotAppears(); + } + + private void startPhotoPickerActivityAndEnableCloudFlag() { + sDevice.waitForIdle(); + launchPhotosActivity(); + mScenario.onActivity( + (activity -> { + activity.getConfigStore() + .enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + getInstrumentation().getTargetContext().getPackageName()); + })); + } + + private void launchPhotosActivity() { + mScenario = ActivityScenario.launchActivityForResult( + PhotoPickerBaseTest.getSingleSelectionIntent()); + } + + private void assertProgressBarAndLoadingTextDoesNotAppears() { + final UiSelector progressBar = new UiSelector().resourceId( + getIsolatedContext().getPackageName() + + ":id/progress_bar"); + assertWithMessage("Waiting for progressBar to appear on photos grid").that( + new UiObject(progressBar).waitForExists(DateUtils.SECOND_IN_MILLIS / 2)).isFalse(); + + final UiSelector loadingText = new UiSelector().resourceId( + getIsolatedContext().getPackageName() + + ":id/loading_text_view"); + assertWithMessage("Waiting for progressBar to appear on photos grid").that( + new UiObject(loadingText).waitForExists(DateUtils.SECOND_IN_MILLIS / 2)).isFalse(); + } + + @After + public void tearDown() { + if (mScenario != null) { + mScenario.close(); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatMultiSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatMultiSelectTest.java index 9a834d05b..589716415 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatMultiSelectTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatMultiSelectTest.java @@ -32,11 +32,12 @@ import static com.android.providers.media.photopicker.espresso.RecyclerViewTestU import androidx.test.ext.junit.rules.ActivityScenarioRule; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; +@RunOnlyOnPostsubmit public class SpecialFormatMultiSelectTest extends SpecialFormatBaseTest { @Rule @@ -124,12 +125,10 @@ public class SpecialFormatMultiSelectTest extends SpecialFormatBaseTest { } @Test - @Ignore("Enable after b/218806007 is fixed") public void testPreview_multiSelect_navigation() throws Exception { onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); // Select items - clickItem(PICKER_TAB_RECYCLERVIEW_ID, IMAGE_1_POSITION, ICON_THUMBNAIL_ID); clickItem(PICKER_TAB_RECYCLERVIEW_ID, GIF_POSITION, ICON_THUMBNAIL_ID); clickItem(PICKER_TAB_RECYCLERVIEW_ID, ANIMATED_WEBP_POSITION, ICON_THUMBNAIL_ID); clickItem(PICKER_TAB_RECYCLERVIEW_ID, MOTION_PHOTO_POSITION, ICON_THUMBNAIL_ID); diff --git a/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatSingleSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatSingleSelectTest.java index 3e2edfc12..25672963c 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatSingleSelectTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatSingleSelectTest.java @@ -16,6 +16,10 @@ package com.android.providers.media.photopicker.espresso; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_ANIMATED_WEBP; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_GIF; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_MOTION_PHOTO; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; @@ -33,11 +37,13 @@ import static org.hamcrest.Matchers.not; import androidx.test.ext.junit.rules.ActivityScenarioRule; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; +import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger.PhotoPickerEvent; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; +@RunOnlyOnPostsubmit public class SpecialFormatSingleSelectTest extends SpecialFormatBaseTest { @Rule @@ -117,6 +123,10 @@ public class SpecialFormatSingleSelectTest extends SpecialFormatBaseTest { // Navigate to preview longClickItem(PICKER_TAB_RECYCLERVIEW_ID, GIF_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + _SPECIAL_FORMAT_GIF, GIF_IMAGE_MIME_TYPE, GIF_POSITION); + try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { // Verify gif icon is displayed for gif preview @@ -133,6 +143,10 @@ public class SpecialFormatSingleSelectTest extends SpecialFormatBaseTest { // Navigate to preview longClickItem(PICKER_TAB_RECYCLERVIEW_ID, ANIMATED_WEBP_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + _SPECIAL_FORMAT_ANIMATED_WEBP, ANIMATED_WEBP_MIME_TYPE, ANIMATED_WEBP_POSITION); + try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { // Verify gif icon is displayed for animated preview @@ -143,7 +157,6 @@ public class SpecialFormatSingleSelectTest extends SpecialFormatBaseTest { } @Test - @Ignore("Enable after b/222013536 is fixed") public void testPreview_singleSelect_nonAnimatedWebp() throws Exception { onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed())); @@ -169,6 +182,10 @@ public class SpecialFormatSingleSelectTest extends SpecialFormatBaseTest { // Navigate to preview longClickItem(PICKER_TAB_RECYCLERVIEW_ID, MOTION_PHOTO_POSITION, ICON_THUMBNAIL_ID); + UiEventLoggerTestUtils.verifyLogWithInstanceIdAndPosition( + mRule, PhotoPickerEvent.PHOTO_PICKER_PREVIEW_ITEM_MAIN_GRID, + _SPECIAL_FORMAT_MOTION_PHOTO, JPEG_IMAGE_MIME_TYPE, MOTION_PHOTO_POSITION); + try (ViewPager2IdlingResource idlingResource = ViewPager2IdlingResource.register(mRule.getScenario(), PREVIEW_VIEW_PAGER_ID)) { // Verify motion photo icon is displayed for motion photo preview diff --git a/tests/src/com/android/providers/media/photopicker/espresso/UiEventLoggerTestUtils.java b/tests/src/com/android/providers/media/photopicker/espresso/UiEventLoggerTestUtils.java new file mode 100644 index 000000000..f088253f6 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/espresso/UiEventLoggerTestUtils.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.photopicker.espresso; + +import static org.mockito.Mockito.verify; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.rules.ActivityScenarioRule; + +import com.android.internal.logging.UiEventLogger; + +public class UiEventLoggerTestUtils { + static void verifyLogWithInstanceId(ActivityScenarioRule<PhotoPickerTestActivity> rule, + UiEventLogger.UiEventEnum event) { + verifyLogWithInstanceId(rule, event, /* uid */ 0, /* packageName */ null); + } + + static void verifyLogWithInstanceId(ActivityScenarioRule<PhotoPickerTestActivity> rule, + UiEventLogger.UiEventEnum event, int uid, String packageName) { + rule.getScenario().onActivity(activity -> + verify(activity.getLogger()).logWithInstanceId( + event, uid, packageName, activity.getInstanceId())); + } + + static void verifyLogWithInstanceIdAndPosition( + ActivityScenarioRule<PhotoPickerTestActivity> rule, + UiEventLogger.UiEventEnum event, int position) { + verifyLogWithInstanceIdAndPosition( + rule, event, /* uid */ 0, /* packageName */ null, position); + } + + static void verifyLogWithInstanceIdAndPosition( + ActivityScenarioRule<PhotoPickerTestActivity> rule, UiEventLogger.UiEventEnum event, + int uid, String packageName, int position) { + verifyLogWithInstanceIdAndPosition(rule.getScenario(), event, uid, packageName, position); + } + + static <T extends PhotoPickerTestActivity> void verifyLogWithInstanceIdAndPosition( + ActivityScenario<T> scenario, UiEventLogger.UiEventEnum event, + int uid, String packageName, int position) { + scenario.onActivity(activity -> + verify(activity.getLogger()) + .logWithInstanceIdAndPosition( + event, uid, packageName, activity.getInstanceId(), position)); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/espresso/ViewPager2IdlingResource.java b/tests/src/com/android/providers/media/photopicker/espresso/ViewPager2IdlingResource.java index a7de9d65d..27521ba69 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/ViewPager2IdlingResource.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/ViewPager2IdlingResource.java @@ -68,8 +68,8 @@ public class ViewPager2IdlingResource implements IdlingResource, AutoCloseable { * @return {@link ViewPager2IdlingResource} that is registered to the activity related to the * given {@link ActivityScenarioRule} and the resource ID of the ViewPager2. */ - public static ViewPager2IdlingResource register( - ActivityScenario<PhotoPickerTestActivity> scenario, int viewPager2Id) { + public static <T extends PhotoPickerTestActivity> ViewPager2IdlingResource register( + ActivityScenario<T> scenario, int viewPager2Id) { final ViewPager2IdlingResource[] idlingResources = new ViewPager2IdlingResource[1]; scenario.onActivity( (activity -> { diff --git a/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java index 7eb2f7fbb..9d0c3f126 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java @@ -27,12 +27,14 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; import com.android.providers.media.R; +import com.android.providers.media.library.RunOnlyOnPostsubmit; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4ClassRunner.class) public class WorkAppsOffProfileButtonTest extends PhotoPickerBaseTest { @BeforeClass diff --git a/tests/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorkerTest.java new file mode 100644 index 000000000..aa1f2f467 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorkerTest.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_CHANNEL_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_ID; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getCloudAlbumSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAlbumSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudAlbumSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncTestWorkParams; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.os.Build; +import android.os.CancellationSignal; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.ForegroundInfo; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.photopicker.PickerSyncController; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.concurrent.ExecutionException; + +// TODO enable tests in Android R after fixing b/293390235 +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class ImmediateAlbumSyncWorkerTest { + @Mock + private PickerSyncController mMockPickerSyncController; + @Mock + private SyncTracker mMockLocalAlbumSyncTracker; + @Mock + private SyncTracker mMockCloudAlbumSyncTracker; + private Context mContext; + + @Before + public void setup() { + initMocks(this); + + // Inject mock trackers + SyncTrackerRegistry.setLocalAlbumSyncTracker(mMockLocalAlbumSyncTracker); + SyncTrackerRegistry.setCloudAlbumSyncTracker(mMockCloudAlbumSyncTracker); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + } + + @After + public void teardown() { + // Reset mock trackers + SyncTrackerRegistry.setLocalAlbumSyncTracker(new SyncTracker()); + SyncTrackerRegistry.setCloudAlbumSyncTracker(new SyncTracker()); + } + + @Test + public void testLocalAlbumImmediateSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateAlbumSyncWorker.class) + .setInputData(getLocalAlbumSyncInputData(/* albumId */ "Not_null")) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAlbumMediaFromLocalProvider(anyString(), any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAlbumMediaFromCloudProvider(anyString(), any(CancellationSignal.class)); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testCloudAlbumImmediateSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateAlbumSyncWorker.class) + .setInputData(getCloudAlbumSyncInputData(/* albumId */ "Not_null")) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAlbumMediaFromLocalProvider(anyString(), any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAlbumMediaFromCloudProvider(anyString(), any(CancellationSignal.class)); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testInvalidSyncSourceImmediateAlbumSync() + throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateAlbumSyncWorker.class) + .setInputData(getLocalAndCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testLocalAndCloudImmediateSyncFailure() + throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(null); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateAlbumSyncWorker.class) + .setInputData(getLocalAndCloudAlbumSyncInputData(/* albumId */ "Not_null")) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testInvalidAlbumIdImmediateSyncFailure() + throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(null); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateAlbumSyncWorker.class) + .setInputData(getLocalAlbumSyncInputData(/* albumId */ "")) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testImmediateAlbumSyncWorkerOnStopped() { + // Setup + final ImmediateAlbumSyncWorker immediateAlbumSyncWorker = + new ImmediateAlbumSyncWorker(mContext, getLocalAndCloudSyncTestWorkParams()); + + // Test onStopped + immediateAlbumSyncWorker.onStopped(); + + // Verify + assertThat(immediateAlbumSyncWorker.getCancellationSignal().isCanceled()).isTrue(); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testGetForegroundInfo() { + final ForegroundInfo foregroundInfo = new ImmediateAlbumSyncWorker( + mContext, getLocalAndCloudSyncTestWorkParams()).getForegroundInfo(); + + assertThat(foregroundInfo.getNotificationId()).isEqualTo(NOTIFICATION_ID); + assertThat(foregroundInfo.getNotification().getChannelId()) + .isEqualTo(NOTIFICATION_CHANNEL_ID); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorkerTest.java new file mode 100644 index 000000000..c28e3b203 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorkerTest.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_CHANNEL_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_ID; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getCloudSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncTestWorkParams; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.os.Build; +import android.os.CancellationSignal; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.ForegroundInfo; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.photopicker.PickerSyncController; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.concurrent.ExecutionException; + +// TODO enable tests in Android R after fixing b/293390235 +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class ImmediateSyncWorkerTest { + @Mock + private PickerSyncController mMockPickerSyncController; + @Mock + private SyncTracker mMockLocalSyncTracker; + @Mock + private SyncTracker mMockCloudSyncTracker; + private Context mContext; + + @Before + public void setup() { + initMocks(this); + + // Inject mock trackers + SyncTrackerRegistry.setLocalSyncTracker(mMockLocalSyncTracker); + SyncTrackerRegistry.setCloudSyncTracker(mMockCloudSyncTracker); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + } + + @After + public void teardown() { + // Reset mock trackers + SyncTrackerRegistry.setLocalSyncTracker(new SyncTracker()); + SyncTrackerRegistry.setCloudSyncTracker(new SyncTracker()); + } + + @Test + public void testLocalImmediateSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateSyncWorker.class) + .setInputData(getLocalSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testCloudImmediateSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(ImmediateSyncWorker.class) + .setInputData(getCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testLocalAndCloudImmediateSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateSyncWorker.class) + .setInputData(getLocalAndCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testLocalAndCloudImmediateSyncFailure() + throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(null); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ImmediateSyncWorker.class) + .setInputData(getLocalAndCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testImmediateSyncWorkerOnStopped() { + // Setup + final ImmediateSyncWorker immediateSyncWorker = + new ImmediateSyncWorker(mContext, getLocalAndCloudSyncTestWorkParams()); + + // Test onStopped + immediateSyncWorker.onStopped(); + + // Verify + assertThat(immediateSyncWorker.getCancellationSignal().isCanceled()).isTrue(); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testGetForegroundInfo() { + final ForegroundInfo foregroundInfo = new ImmediateSyncWorker( + mContext, getLocalAndCloudSyncTestWorkParams()).getForegroundInfo(); + + assertThat(foregroundInfo.getNotificationId()).isEqualTo(NOTIFICATION_ID); + assertThat(foregroundInfo.getNotification().getChannelId()) + .isEqualTo(NOTIFICATION_CHANNEL_ID); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/MediaResetWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/MediaResetWorkerTest.java new file mode 100644 index 000000000..18f89fb19 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/MediaResetWorkerTest.java @@ -0,0 +1,469 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_RESET_ALBUM; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_RESET_TYPE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_TAG_IS_PERIODIC; +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_CHANNEL_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_ID; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getAlbumResetInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncTestWorkParams; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.os.Build; +import android.provider.CloudMediaProviderContract.MediaColumns; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.Data; +import androidx.work.ForegroundInfo; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +// TODO enable tests in Android R after fixing b/293390235 +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class MediaResetWorkerTest { + + private PickerSyncController mExistingPickerSyncController; + + @Mock private PickerSyncController mMockPickerSyncController; + @Mock private SyncTracker mMockLocalAlbumSyncTracker; + @Mock private SyncTracker mMockCloudAlbumSyncTracker; + + private PickerDbFacade mDbFacade; + private Context mContext; + + private static final String TEST_ALBUM_ID_1 = "test-album-id-1"; + private static final String TEST_ALBUM_ID_2 = "test-album-id-2"; + private static final String TEST_ALBUM_ID_3 = "test-album-id-3"; + private static final String TEST_ALBUM_ID_4 = "test-album-id-4"; + private static final String TEST_LOCAL_AUTHORITY = "com.android.media.photopicker"; + private static final String TEST_CLOUD_AUTHORITY = "com.hooli.super.awesome.cloud.provider"; + + @Before + public void setup() { + initMocks(this); + + try { + mExistingPickerSyncController = PickerSyncController.getInstanceOrThrow(); + } catch (IllegalStateException ignored) { + } + + // Inject mock trackers + SyncTrackerRegistry.setLocalAlbumSyncTracker(mMockLocalAlbumSyncTracker); + SyncTrackerRegistry.setCloudAlbumSyncTracker(mMockCloudAlbumSyncTracker); + + doReturn(new PickerSyncLockManager()) + .when(mMockPickerSyncController).getPickerSyncLockManager(); + doReturn(TEST_CLOUD_AUTHORITY).when(mMockPickerSyncController).getCloudProvider(); + doReturn(TEST_LOCAL_AUTHORITY).when(mMockPickerSyncController).getLocalProvider(); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + // Cleanup previous test run databases. + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + + mDbFacade = new PickerDbFacade(mContext, new PickerSyncLockManager(), TEST_LOCAL_AUTHORITY); + mDbFacade.setCloudProvider(TEST_CLOUD_AUTHORITY); + + initializeTestWorkManager(mContext); + PickerSyncController.setInstance(mMockPickerSyncController); + } + + @After + public void teardown() { + if (mExistingPickerSyncController != null) { + PickerSyncController.setInstance(mExistingPickerSyncController); + } + + // Reset mock trackers + SyncTrackerRegistry.setLocalAlbumSyncTracker(new SyncTracker()); + SyncTrackerRegistry.setCloudAlbumSyncTracker(new SyncTracker()); + } + + @Test + public void testResetCloudAlbumMediaForAlbumId() + throws ExecutionException, InterruptedException { + + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_1, TEST_CLOUD_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_3, TEST_LOCAL_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_4, TEST_LOCAL_AUTHORITY); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaResetWorker.class) + .setInputData( + getAlbumResetInputData( + TEST_ALBUM_ID_1, TEST_CLOUD_AUTHORITY, false)) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + // We should have deleted just the rows related to the TEST_ALBUM_ID_1 album. + Cursor cursor = queryAlbumMediaAll(TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(60); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_1, TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(20); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_3, TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(20); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_4, TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(20); + cursor.close(); + + // The sync future is created by the PickerSyncManager before the request is + // enqueued. + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + + // The worker should resolve its own sync future. + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testResetLocalAlbumMediaForAlbumId() + throws ExecutionException, InterruptedException { + + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_1, TEST_LOCAL_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaResetWorker.class) + .setInputData( + getAlbumResetInputData(TEST_ALBUM_ID_1, TEST_CLOUD_AUTHORITY, true)) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + + // We should have deleted just the rows related to the TEST_ALBUM_ID_1 album. + Cursor cursor = queryAlbumMediaAll(TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(20); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_1, TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(20); + cursor.close(); + + // The sync future is created by the PickerSyncManager before the request is + // enqueued. + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + + // The worker should resolve its own sync future. + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testResetAllAlbumMedia() throws ExecutionException, InterruptedException { + + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_1, TEST_CLOUD_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + + final Data requestData = + new Data( + Map.of( + SYNC_WORKER_INPUT_AUTHORITY, + TEST_CLOUD_AUTHORITY, + SYNC_WORKER_INPUT_RESET_TYPE, + SYNC_RESET_ALBUM, + SYNC_WORKER_INPUT_SYNC_SOURCE, + SYNC_LOCAL_AND_CLOUD)); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaResetWorker.class) + .setInputData(requestData) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + Cursor cursor = queryAlbumMediaAll(TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + + // The sync future is created by the PickerSyncManager before the request is + // enqueued. + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + + // The worker should resolve its own sync future. + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testPeriodicWorkerAlbumReset_WithCloudProvider() + throws ExecutionException, InterruptedException { + + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_1, TEST_LOCAL_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_2, TEST_CLOUD_AUTHORITY); + + final Data requestData = + new Data( + Map.of( + SYNC_WORKER_INPUT_RESET_TYPE, + SYNC_RESET_ALBUM, + SYNC_WORKER_INPUT_SYNC_SOURCE, + SYNC_LOCAL_AND_CLOUD)); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaResetWorker.class) + .setInputData(requestData) + .addTag(SYNC_WORKER_TAG_IS_PERIODIC) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + // The sync future is created by the PickerSyncManager before the request is + // enqueued. + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + Cursor cursor = queryAlbumMediaAll(TEST_CLOUD_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + + cursor = queryAlbumMediaAll(TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + } + + @Test + public void testPeriodicWorkerAlbumReset_WithLocalProvider() + throws ExecutionException, InterruptedException { + + doReturn(null).when(mMockPickerSyncController).getCloudProvider(); + + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_1, TEST_LOCAL_AUTHORITY); + assertAddAlbumMediaWithAlbumId(TEST_ALBUM_ID_2, TEST_LOCAL_AUTHORITY); + + final Data requestData = + new Data( + Map.of( + SYNC_WORKER_INPUT_RESET_TYPE, + SYNC_RESET_ALBUM, + SYNC_WORKER_INPUT_SYNC_SOURCE, + SYNC_LOCAL_AND_CLOUD)); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaResetWorker.class) + .setInputData(requestData) + .addTag(SYNC_WORKER_TAG_IS_PERIODIC) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + // The sync future is created by the PickerSyncManager before the request is + // enqueued. + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + Cursor cursor = queryAlbumMediaAll(TEST_LOCAL_AUTHORITY); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + } + + @Test + public void testMediaResetWorkerOnStopped() { + new MediaResetWorker(mContext, getLocalAndCloudSyncTestWorkParams()).onStopped(); + + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudAlbumSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testGetForegroundInfo() { + final ForegroundInfo foregroundInfo = new MediaResetWorker( + mContext, getLocalAndCloudSyncTestWorkParams()).getForegroundInfo(); + + assertThat(foregroundInfo.getNotificationId()).isEqualTo(NOTIFICATION_ID); + assertThat(foregroundInfo.getNotification().getChannelId()) + .isEqualTo(NOTIFICATION_CHANNEL_ID); + } + + /** + * Builds a suitible mock Album media cursor that could be returned from a provider. + * + * @param id a base id for each file. will be appended with the current loop count. + */ + private static Cursor getAlbumMediaCursor(String id) { + String[] projectionKey = + new String[] { + MediaColumns.ID, + MediaColumns.MEDIA_STORE_URI, + MediaColumns.DATE_TAKEN_MILLIS, + MediaColumns.SYNC_GENERATION, + MediaColumns.SIZE_BYTES, + MediaColumns.MIME_TYPE, + MediaColumns.STANDARD_MIME_TYPE_EXTENSION, + MediaColumns.DURATION_MILLIS, + }; + + MatrixCursor c = new MatrixCursor(projectionKey); + int counter = 0; + + while (++counter <= 20) { + + String[] projectionValue = + new String[] { + id + counter, + "content://media/external/file/1234" + counter, + String.valueOf(System.nanoTime()), + String.valueOf(1), + String.valueOf(1234), + "image/png", + String.valueOf(MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE), + String.valueOf(1234), + }; + + c.addRow(projectionValue); + } + return c; + } + + /** + * Query all records in the Album media table. + * + * @param authority provider's authority + */ + private Cursor queryAlbumMediaAll(String authority) { + return mDbFacade.queryAlbumMediaForUi( + new PickerDbFacade.QueryFilterBuilder(1000).build(), authority); + } + + /** + * @param albumId limit the results to just files present in this album + * @param authority provider's authority + */ + private Cursor queryAlbumMediaAll(String albumId, String authority) { + return mDbFacade.queryAlbumMediaForUi( + new PickerDbFacade.QueryFilterBuilder(1000).setAlbumId(albumId).build(), authority); + } + + /** + * Creates a fake Album with the given Album ID and adds 20 fake files to it. + * + * @param albumId the id to use in creating the fake album + * @param authority the provider that owns the fake album. + */ + private void assertAddAlbumMediaWithAlbumId(String albumId, String authority) { + + try (PickerDbFacade.DbWriteOperation operation = + mDbFacade.beginAddAlbumMediaOperation(authority, albumId)) { + operation.execute(getAlbumMediaCursor("1234-" + albumId)); + operation.setSuccess(); + } + + Cursor cr = queryAlbumMediaAll(albumId, authority); + assertThat(cr.getCount()).isEqualTo(20); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/PickerSyncLockManagerTest.java b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncLockManagerTest.java new file mode 100644 index 000000000..de01adbb8 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncLockManagerTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncLockManager.CLOUD_ALBUM_SYNC_LOCK; +import static com.android.providers.media.photopicker.sync.PickerSyncLockManager.CLOUD_SYNC_LOCK; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Handler; +import android.os.HandlerThread; + +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; + +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class PickerSyncLockManagerTest { + private PickerSyncLockManager mSyncLockManager; + + @Before + public void setup() { + mSyncLockManager = new PickerSyncLockManager(); + } + + @Test + public void testLockIsCloseable() { + try (CloseableReentrantLock lock = mSyncLockManager.lock(CLOUD_SYNC_LOCK)) { + // Assert that the lock is help by the current thread. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock.getHoldCount()).isEqualTo(1); + + try (CloseableReentrantLock lockInLock = mSyncLockManager.lock(CLOUD_SYNC_LOCK)) { + // Assert that this is a reentrant lock and the thread was able to increment hold + // count. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock).isEqualTo(lockInLock); + assertThat(lock.getHoldCount()).isEqualTo(2); + } + + // Assert that the hold count has been decremented. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock.getHoldCount()).isEqualTo(1); + assertThat(lock).isEqualTo(mSyncLockManager.getLock(CLOUD_SYNC_LOCK)); + } + + assertThat(mSyncLockManager.getLock(CLOUD_SYNC_LOCK).isHeldByCurrentThread()).isFalse(); + } + + @Test + public void testLockWithTimeoutIsCloseable() throws UnableToAcquireLockException { + try (CloseableReentrantLock lock = mSyncLockManager.tryLock(CLOUD_ALBUM_SYNC_LOCK)) { + // Assert that the lock is help by the current thread. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock.getHoldCount()).isEqualTo(1); + + try (CloseableReentrantLock lockInLock = + mSyncLockManager.tryLock(CLOUD_ALBUM_SYNC_LOCK)) { + // Assert that this is a reentrant lock and the thread was able to increment hold + // count. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock).isEqualTo(lockInLock); + assertThat(lock.getHoldCount()).isEqualTo(2); + } + + // Assert that the hold count has been decremented. + assertThat(lock.isHeldByCurrentThread()).isTrue(); + assertThat(lock.getHoldCount()).isEqualTo(1); + assertThat(lock).isEqualTo(mSyncLockManager.getLock(CLOUD_ALBUM_SYNC_LOCK)); + } + + assertThat(mSyncLockManager.getLock(CLOUD_ALBUM_SYNC_LOCK).isHeldByCurrentThread()) + .isFalse(); + } + + @Test + public void testLockTimeout() throws InterruptedException, TimeoutException { + CloseableReentrantLock lock = new CloseableReentrantLock("testLock"); + try (CloseableReentrantLock ignored = + mSyncLockManager.tryLock(lock, 5, TimeUnit.MILLISECONDS)) { + // it is expected that the lock is held by the current thread within timeout. + } catch (UnableToAcquireLockException e) { + throw new AssertionError( + "Should be able to acquire the lock since no other thread holds it.", e); + } + + HandlerThread thread = new HandlerThread("PickerSyncLockTestThread", + android.os.Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + Handler handler = new Handler(thread.getLooper()); + acquireLock(handler, lock); + + try (CloseableReentrantLock ignored = + mSyncLockManager.tryLock(lock, 5, TimeUnit.MILLISECONDS)) { + throw new AssertionError("The lock should not be acquired by this thread because " + + "it is already held by a different thread"); + } catch (UnableToAcquireLockException e) { + // The expectation is that lock is not acquired within the timeout and + // UnableToAcquireLockException is thrown. + } + + releaseLock(handler, lock); + thread.quitSafely(); + } + + private void acquireLock(Handler handler, CloseableReentrantLock lock) + throws InterruptedException, TimeoutException { + handler.post(() -> lock.lock()); + waitForHandler(handler); + } + + private void releaseLock(Handler handler, CloseableReentrantLock lock) + throws InterruptedException, TimeoutException { + handler.post(() -> lock.unlock()); + waitForHandler(handler); + } + + private void waitForHandler(Handler handler) throws InterruptedException, TimeoutException { + final CountDownLatch latch = new CountDownLatch(1); + handler.post(() -> latch.countDown()); + final boolean success = latch.await(30, TimeUnit.SECONDS); + if (!success) { + throw new TimeoutException("Could not wait for handler task to finish"); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java new file mode 100644 index 000000000..ae7221ca5 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.content.res.Resources; + +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.Operation; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkContinuation; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; + +import com.android.modules.utils.BackgroundThread; +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.photopicker.PickerSyncController; + +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class PickerSyncManagerTest { + private PickerSyncManager mPickerSyncManager; + private TestConfigStore mConfigStore; + @Mock + private WorkManager mMockWorkManager; + @Mock + private Operation mMockOperation; + @Mock + private WorkContinuation mMockWorkContinuation; + @Mock + private ListenableFuture<Operation.State.SUCCESS> mMockFuture; + @Mock + private Context mMockContext; + @Mock + private Resources mResources; + @Captor + ArgumentCaptor<PeriodicWorkRequest> mPeriodicWorkRequestArgumentCaptor; + @Captor + ArgumentCaptor<OneTimeWorkRequest> mOneTimeWorkRequestArgumentCaptor; + @Captor + ArgumentCaptor<List<OneTimeWorkRequest>> mOneTimeWorkRequestListArgumentCaptor; + + @Before + public void setUp() { + initMocks(this); + doReturn(mResources).when(mMockContext).getResources(); + mConfigStore = new TestConfigStore(); + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + "com.hooli.super.awesome.cloudpicker"); + } + + @Test + public void testSchedulePeriodicSyncs() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ true); + + verify(mMockWorkManager, times(2)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + final PeriodicWorkRequest periodicWorkRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(0); + assertThat(periodicWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(periodicWorkRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicWorkRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + final PeriodicWorkRequest periodicResetRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(1); + assertThat(periodicResetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(periodicResetRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicResetRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + } + + @Test + public void testPeriodicWorkIsScheduledOnDeviceConfigChanges() { + + mConfigStore.disableCloudMediaFeature(); + + + setupPickerSyncManager(true); + + // Ensure no syncs have been scheduled yet. + verify(mMockWorkManager, times(0)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + "com.hooli.some.cloud.provider"); + + waitForIdle(); + + // Ensure the syncs are now scheduled. + verify(mMockWorkManager, times(2)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + final PeriodicWorkRequest periodicWorkRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(0); + assertThat(periodicWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(periodicWorkRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicWorkRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + final PeriodicWorkRequest periodicResetRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(1); + assertThat(periodicResetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(periodicResetRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicResetRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + clearInvocations(mMockWorkManager); + + mConfigStore.disableCloudMediaFeature(); + waitForIdle(); + + verify(mMockWorkManager, times(2)).cancelUniqueWork(anyString()); + } + + @Test + public void testAdhocProactiveSyncLocalOnly() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncMediaProactively(/* localOnly */ true); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), + any(), + mOneTimeWorkRequestArgumentCaptor.capture()); + + final OneTimeWorkRequest workRequest = mOneTimeWorkRequestArgumentCaptor.getValue(); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isFalse(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isTrue(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + } + + @Test + public void testAdhocProactiveSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncMediaProactively(/* localOnly */ false); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), + any(), + mOneTimeWorkRequestArgumentCaptor.capture()); + + final OneTimeWorkRequest workRequest = mOneTimeWorkRequestArgumentCaptor.getValue(); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isFalse(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isTrue(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + } + + @Test + public void testImmediateLocalSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncMediaImmediately(true); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final OneTimeWorkRequest workRequest = mOneTimeWorkRequestArgumentCaptor.getValue(); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(ImmediateSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + } + + @Test + public void testImmediateCloudSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncMediaImmediately(false); + verify(mMockWorkManager, times(2)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final List<OneTimeWorkRequest> workRequestList = + mOneTimeWorkRequestArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(2); + + WorkRequest localWorkRequest = workRequestList.get(0); + assertThat(localWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ImmediateSyncWorker.class.getName()); + assertThat(localWorkRequest.getWorkSpec().expedited).isTrue(); + assertThat(localWorkRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(localWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(localWorkRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(localWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + + WorkRequest cloudWorkRequest = workRequestList.get(1); + assertThat(cloudWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ImmediateSyncWorker.class.getName()); + assertThat(cloudWorkRequest.getWorkSpec().expedited).isTrue(); + assertThat(cloudWorkRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(cloudWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(cloudWorkRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(cloudWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + } + + @Test + public void testImmediateLocalAlbumSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncAlbumMediaForProviderImmediately( + "Not_null", PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); + verify(mMockWorkManager, times(1)) + .beginUniqueWork( + anyString(), + any(ExistingWorkPolicy.class), + mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation, times(1)) + .then(mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation).enqueue(); + + final OneTimeWorkRequest resetRequest = + mOneTimeWorkRequestListArgumentCaptor.getAllValues().get(0).get(0); + assertThat(resetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(resetRequest.getWorkSpec().expedited).isTrue(); + assertThat(resetRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(resetRequest.getWorkSpec().id).isNotNull(); + assertThat(resetRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(resetRequest.getWorkSpec().input.getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + + final OneTimeWorkRequest workRequest = + mOneTimeWorkRequestListArgumentCaptor.getAllValues().get(1).get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(ImmediateAlbumSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input.getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + } + + @Test + public void testImmediateCloudAlbumSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncAlbumMediaForProviderImmediately( + "Not_null", "com.hooli.cloudpicker"); + verify(mMockWorkManager, times(1)) + .beginUniqueWork( + anyString(), + any(ExistingWorkPolicy.class), + mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation, times(1)) + .then(mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation).enqueue(); + + final OneTimeWorkRequest resetRequest = + mOneTimeWorkRequestListArgumentCaptor.getAllValues().get(0).get(0); + assertThat(resetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(resetRequest.getWorkSpec().expedited).isTrue(); + assertThat(resetRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(resetRequest.getWorkSpec().id).isNotNull(); + assertThat(resetRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(resetRequest.getWorkSpec().input.getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + + final OneTimeWorkRequest workRequest = + mOneTimeWorkRequestListArgumentCaptor.getAllValues().get(1).get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(ImmediateAlbumSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input.getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + } + + @Test + public void testUniqueWorkStatusForPendingWork() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + final String workName = "testWorkName"; + final SettableFuture<List<WorkInfo>> future = SettableFuture.create(); + final List<WorkInfo> futureResult = new ArrayList<>(); + futureResult.add(getWorkInfo(WorkInfo.State.SUCCEEDED)); + futureResult.add(getWorkInfo(WorkInfo.State.ENQUEUED)); + future.set(futureResult); + doReturn(future).when(mMockWorkManager) + .getWorkInfosForUniqueWork(workName); + + assertThat(mPickerSyncManager.isUniqueWorkPending(workName)).isTrue(); + } + + @Test + public void testUniqueWorkStatusForCompletedWork() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + final String workName = "testWorkName"; + final SettableFuture<List<WorkInfo>> future = SettableFuture.create(); + final List<WorkInfo> futureResult = new ArrayList<>(); + futureResult.add(getWorkInfo(WorkInfo.State.SUCCEEDED)); + futureResult.add(getWorkInfo(WorkInfo.State.FAILED)); + futureResult.add(getWorkInfo(WorkInfo.State.CANCELLED)); + future.set(futureResult); + doReturn(future).when(mMockWorkManager) + .getWorkInfosForUniqueWork(workName); + + assertThat(mPickerSyncManager.isUniqueWorkPending(workName)).isFalse(); + } + + private WorkInfo getWorkInfo(WorkInfo.State state) { + return new WorkInfo(UUID.randomUUID(), state, new HashSet<>()); + } + + private void setupPickerSyncManager(boolean schedulePeriodicSyncs) { + doReturn(mMockOperation).when(mMockWorkManager) + .enqueueUniquePeriodicWork(anyString(), + any(ExistingPeriodicWorkPolicy.class), + any(PeriodicWorkRequest.class)); + doReturn(mMockOperation).when(mMockWorkManager) + .enqueueUniqueWork(anyString(), + any(ExistingWorkPolicy.class), + any(OneTimeWorkRequest.class)); + doReturn(mMockWorkContinuation) + .when(mMockWorkManager) + .beginUniqueWork( + anyString(), any(ExistingWorkPolicy.class), any(List.class)); + // Handle .then chaining + doReturn(mMockWorkContinuation) + .when(mMockWorkContinuation) + .then(any(List.class)); + doReturn(mMockOperation).when(mMockWorkContinuation).enqueue(); + doReturn(mMockFuture).when(mMockOperation).getResult(); + + mPickerSyncManager = + new PickerSyncManager(mMockWorkManager, mMockContext, + mConfigStore, schedulePeriodicSyncs); + } + + private static void waitForIdle() { + final CountDownLatch latch = new CountDownLatch(1); + BackgroundThread.getExecutor().execute(latch::countDown); + try { + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + + } + +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorkerTest.java new file mode 100644 index 000000000..5d11b3e11 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorkerTest.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_CHANNEL_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncNotificationHelper.NOTIFICATION_ID; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getCloudSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalAndCloudSyncTestWorkParams; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.os.Build; +import android.os.CancellationSignal; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.ForegroundInfo; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.photopicker.PickerSyncController; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.concurrent.ExecutionException; + +// TODO enable tests in Android R after fixing b/293390235 +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class ProactiveSyncWorkerTest { + @Mock + private PickerSyncController mMockPickerSyncController; + @Mock + private SyncTracker mMockLocalSyncTracker; + @Mock + private SyncTracker mMockCloudSyncTracker; + private Context mContext; + + @Before + public void setup() { + initMocks(this); + + // Inject mock trackers + SyncTrackerRegistry.setLocalSyncTracker(mMockLocalSyncTracker); + SyncTrackerRegistry.setCloudSyncTracker(mMockCloudSyncTracker); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + } + + @After + public void teardown() { + // Reset mock trackers + SyncTrackerRegistry.setLocalSyncTracker(new SyncTracker()); + SyncTrackerRegistry.setCloudSyncTracker(new SyncTracker()); + } + + @Test + public void testLocalProactiveSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ProactiveSyncWorker.class) + .setInputData(getLocalSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testCloudProactiveSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ProactiveSyncWorker.class) + .setInputData(getCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testLocalAndCloudProactiveSync() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(mMockPickerSyncController); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ProactiveSyncWorker.class) + .setInputData(getLocalAndCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 1)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testProactiveSyncFailure() throws ExecutionException, InterruptedException { + // Setup + PickerSyncController.setInstance(null); + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(ProactiveSyncWorker.class) + .setInputData(getLocalAndCloudSyncInputData()) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromLocalProvider(any(CancellationSignal.class)); + verify(mMockPickerSyncController, times(/* wantedNumberOfInvocations */ 0)) + .syncAllMediaFromCloudProvider(any(CancellationSignal.class)); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testProactiveSyncWorkerOnStopped() { + // Setup + final ProactiveSyncWorker proactiveSyncWorker = + new ProactiveSyncWorker(mContext, getLocalAndCloudSyncTestWorkParams()); + + // Test onStopped + proactiveSyncWorker.onStopped(); + + // Verify + assertThat(proactiveSyncWorker.getCancellationSignal().isCanceled()).isTrue(); + + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testGetForegroundInfo() { + final ForegroundInfo foregroundInfo = new ProactiveSyncWorker( + mContext, getLocalAndCloudSyncTestWorkParams()).getForegroundInfo(); + + assertThat(foregroundInfo.getNotificationId()).isEqualTo(NOTIFICATION_ID); + assertThat(foregroundInfo.getNotification().getChannelId()) + .isEqualTo(NOTIFICATION_CHANNEL_ID); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java b/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java new file mode 100644 index 000000000..ed1d117b7 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class SyncTrackerTests { + private static final UUID PLACEHOLDER_UUID = UUID.randomUUID(); + + @Test + public void testCreateSyncFuture() { + SyncTracker syncTracker = new SyncTracker(); + syncTracker.createSyncFuture(PLACEHOLDER_UUID); + + Collection<CompletableFuture<Object>> futures = syncTracker.pendingSyncFutures(); + assertThat(futures.size()).isEqualTo(1); + } + + @Test + public void testMarkSyncAsComplete() { + SyncTracker syncTracker = new SyncTracker(); + syncTracker.createSyncFuture(PLACEHOLDER_UUID); + syncTracker.markSyncCompleted(PLACEHOLDER_UUID); + + Collection<CompletableFuture<Object>> futures = syncTracker.pendingSyncFutures(); + assertThat(futures.size()).isEqualTo(0); + } + + @Test + public void testCompleteOnTimeoutSyncFuture() + throws InterruptedException, ExecutionException, TimeoutException { + SyncTracker syncTracker = new SyncTracker(); + syncTracker.createSyncFuture(PLACEHOLDER_UUID, 100L, TimeUnit.MILLISECONDS); + + Collection<CompletableFuture<Object>> pendingSyncFutures = syncTracker.pendingSyncFutures(); + for (CompletableFuture<Object> future : pendingSyncFutures) { + future.get(200, TimeUnit.MILLISECONDS); + } + assertThat(syncTracker.pendingSyncFutures().size()).isEqualTo(0); + } + + @Test + public void getSyncTrackerFromRegistry() { + assertThat(SyncTrackerRegistry.getSyncTracker(/* isLocal */ true)) + .isEqualTo(SyncTrackerRegistry.getLocalSyncTracker()); + assertThat(SyncTrackerRegistry.getSyncTracker(/* isLocal */ false)) + .isEqualTo(SyncTrackerRegistry.getCloudSyncTracker()); + assertThat(SyncTrackerRegistry.getAlbumSyncTracker(/* isLocal */ true)) + .isEqualTo(SyncTrackerRegistry.getLocalAlbumSyncTracker()); + assertThat(SyncTrackerRegistry.getAlbumSyncTracker(/* isLocal */ false)) + .isEqualTo(SyncTrackerRegistry.getCloudAlbumSyncTracker()); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java b/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java new file mode 100644 index 000000000..7307c157b --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2023 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.sync; + +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_RESET_ALBUM; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_ALBUM_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_RESET_TYPE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; + +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Configuration; +import androidx.work.Data; +import androidx.work.ForegroundUpdater; +import androidx.work.ProgressUpdater; +import androidx.work.WorkerFactory; +import androidx.work.WorkerParameters; +import androidx.work.impl.utils.taskexecutor.TaskExecutor; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.Executor; + +public class SyncWorkerTestUtils { + public static void initializeTestWorkManager(@NonNull Context context) { + Configuration workManagerConfig = new Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(new SynchronousExecutor()) // This runs WM synchronously. + .build(); + + WorkManagerTestInitHelper.initializeTestWorkManager( + context, workManagerConfig); + } + + @NonNull + public static Data getLocalSyncInputData() { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY)); + } + + @NonNull + public static Data getLocalAlbumSyncInputData(@NonNull String albumId) { + Objects.requireNonNull(albumId); + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_ALBUM_ID, albumId)); + } + + @NonNull + public static Data getCloudSyncInputData() { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY)); + } + + @NonNull + public static Data getAlbumResetInputData( + @NonNull String albumId, String authority, boolean isLocal) { + Objects.requireNonNull(albumId); + Objects.requireNonNull(authority); + return new Data( + Map.of( + SYNC_WORKER_INPUT_AUTHORITY, authority, + SYNC_WORKER_INPUT_SYNC_SOURCE, isLocal ? SYNC_LOCAL_ONLY : SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_RESET_TYPE, SYNC_RESET_ALBUM, + SYNC_WORKER_INPUT_ALBUM_ID, albumId)); + } + + @NonNull + public static Data getCloudAlbumSyncInputData(@NonNull String albumId) { + Objects.requireNonNull(albumId); + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_ALBUM_ID, albumId)); + } + + @NonNull + public static Data getLocalAndCloudSyncInputData() { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_AND_CLOUD)); + } + + public static Data getLocalAndCloudAlbumSyncInputData(@NonNull String albumId) { + Objects.requireNonNull(albumId); + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_AND_CLOUD, + SYNC_WORKER_INPUT_ALBUM_ID, albumId)); + } + + /** + * All the values used in the construction of {@link WorkerParameters} here are default + * {@link NonNull} values except the {@link Data inputData} which is derived from + * {@link SyncWorkerTestUtils#getLocalAndCloudSyncInputData()}. + */ + static WorkerParameters getLocalAndCloudSyncTestWorkParams() { + return new WorkerParameters( + UUID.randomUUID(), + getLocalAndCloudSyncInputData(), + /* tags= */ Collections.emptyList(), + new WorkerParameters.RuntimeExtras(), + /* runAttemptCount= */ 0, + /* generation= */ 0, + mock(Executor.class), + mock(TaskExecutor.class), + mock(WorkerFactory.class), + mock(ProgressUpdater.class), + mock(ForegroundUpdater.class)); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/ui/PhotosTabAdapterTest.java b/tests/src/com/android/providers/media/photopicker/ui/PhotosTabAdapterTest.java index ef1537b68..169e86a85 100644 --- a/tests/src/com/android/providers/media/photopicker/ui/PhotosTabAdapterTest.java +++ b/tests/src/com/android/providers/media/photopicker/ui/PhotosTabAdapterTest.java @@ -33,6 +33,8 @@ import com.android.providers.media.photopicker.data.Selection; import com.android.providers.media.photopicker.data.model.Item; import com.android.providers.media.photopicker.ui.PhotosTabAdapter.DateHeader; +import com.bumptech.glide.util.ViewPreloadSizeProvider; + import org.junit.Test; import org.junit.runner.RunWith; @@ -176,8 +178,8 @@ public class PhotosTabAdapterTest { private static PhotosTabAdapter createAdapter(boolean shouldShowRecentSection) { return new PhotosTabAdapter(/* showRecentSection */ shouldShowRecentSection, - mock(Selection.class), mock(ImageLoader.class), mock(View.OnClickListener.class), - mock(View.OnLongClickListener.class), mock(LifecycleOwner.class), + mock(Selection.class), mock(ImageLoader.class), + mock(PhotosTabAdapter.OnMediaItemClickListener.class), mock(LifecycleOwner.class), /* cloudMediaProviderAppTitle */ mock(LiveData.class), /* cloudMediaAccountName */ mock(LiveData.class), /* shouldShowChooseAppBanner */ mock(LiveData.class), @@ -190,7 +192,9 @@ public class PhotosTabAdapterTest { /* onAccountUpdatedBannerEventListener */ mock(TabAdapter.OnBannerEventListener.class), /* onChooseAccountBannerEventListener */ - mock(TabAdapter.OnBannerEventListener.class)); + mock(TabAdapter.OnBannerEventListener.class), + /* onHoverListener */ mock(View.OnHoverListener.class), + /* mPreloadSizeProvider */ mock(ViewPreloadSizeProvider.class)); } private static Item generateFakeImageItem(String id) { diff --git a/tests/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModelTest.java b/tests/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModelTest.java index fec1195c0..a178e7682 100644 --- a/tests/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModelTest.java +++ b/tests/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModelTest.java @@ -60,9 +60,12 @@ import java.util.List; @RunWith(AndroidJUnit4.class) public class SettingsCloudMediaViewModelTest { + private static final String PKG1 = "com.providers.test1"; + private static final String PKG2 = "com.providers.test2"; private static final List<String> sProviderAuthorities = - List.of("cloud_provider_1", "cloud_provider_2"); + List.of(PKG1 + ".cloud_provider_1", PKG2 + ".cloud_provider_2"); private static final List<ResolveInfo> sAvailableProviders = getAvailableProviders(); + private static final List<String> sAllowlistedPackages = List.of(PKG1); @Mock private ConfigStore mConfigStore; @@ -163,11 +166,44 @@ public class SettingsCloudMediaViewModelTest { .call(eq(SET_CLOUD_PROVIDER_CALL), any(), any()); } + @Test + public void testLoadDataWithAllowListedProviders() throws RemoteException { + final String expectedCloudProvider = sProviderAuthorities.get(0); + setUpCurrentCloudProvider(expectedCloudProvider); + setUpAvailableCloudProviders(sAvailableProviders); + setUpAllowedCloudPackages(sAllowlistedPackages); + + mCloudMediaViewModel.loadData(mConfigStore); + + // Verify cloud provider options + final List<CloudMediaProviderOption> providerOptions = + mCloudMediaViewModel.getProviderOptions(); + assertThat(providerOptions.size()).isEqualTo(sAllowlistedPackages.size() + 1); + for (int i = 0; i < sAllowlistedPackages.size(); i++) { + final int lastDotIndex = providerOptions.get(i).getKey().lastIndexOf('.'); + final String providerOptionsPackage = providerOptions.get(i).getKey().substring(0, + lastDotIndex); + assertThat(sAllowlistedPackages).contains(providerOptionsPackage); + } + assertThat(providerOptions.get(providerOptions.size() - 1).getKey()) + .isEqualTo(SettingsCloudMediaViewModel.NONE_PREF_KEY); + + // Verify selected cloud provider + final String resultCloudProvider = + mCloudMediaViewModel.getSelectedProviderAuthority(); + assertThat(resultCloudProvider).isEqualTo(expectedCloudProvider); + } + private void setUpAvailableCloudProviders(@NonNull List<ResolveInfo> availableProviders) { doReturn(availableProviders).when(mPackageManager) .queryIntentContentProvidersAsUser(any(), eq(0), any()); } + private void setUpAllowedCloudPackages(@NonNull List<String> allowlistedPackages) { + doReturn(true).when(mConfigStore).shouldEnforceCloudProviderAllowlist(); + doReturn(allowlistedPackages).when(mConfigStore).getAllowedCloudProviderPackages(); + } + private void setUpCurrentCloudProvider(@Nullable String providerAuthority) throws RemoteException { final Bundle result = new Bundle(); @@ -195,15 +231,17 @@ public class SettingsCloudMediaViewModelTest { @NonNull private static ProviderInfo createProviderInfo(@NonNull String authority) { final ProviderInfo providerInfo = new ProviderInfo(); + final int lastDotIndex = authority.lastIndexOf('.'); providerInfo.authority = authority; providerInfo.readPermission = MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION; + providerInfo.packageName = authority.substring(0, lastDotIndex); providerInfo.applicationInfo = createApplicationInfo(authority); return providerInfo; } @NonNull private static ApplicationInfo createApplicationInfo(@NonNull String authority) { - final ApplicationInfo applicationInfo = new ApplicationInfo(); + final ApplicationInfo applicationInfo = new ApplicationInfo(); applicationInfo.packageName = authority; applicationInfo.uid = 0; return applicationInfo; diff --git a/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java b/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java new file mode 100644 index 000000000..8649d4d56 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 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.util; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.InstrumentationRegistry; + +import com.android.providers.media.IsolatedContext; +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.cloudproviders.CloudProviderPrimary; +import com.android.providers.media.cloudproviders.CloudProviderSecondary; +import com.android.providers.media.cloudproviders.FlakyCloudProvider; +import com.android.providers.media.photopicker.data.CloudProviderInfo; + +import org.junit.Test; + +import java.util.List; +import java.util.Set; + + +public class CloudProviderUtilsTest { + + @Test + public void getAllAvailableCloudProvidersTest() { + final Context context = InstrumentationRegistry.getTargetContext(); + final Context isolatedContext = + new IsolatedContext(context, "CloudProviderUtilsTest", /*asFuseThread*/ false); + final Set<String> testCloudProviders = Set.of( + FlakyCloudProvider.AUTHORITY, + CloudProviderPrimary.AUTHORITY, + CloudProviderSecondary.AUTHORITY); + final TestConfigStore configStore = new TestConfigStore(); + configStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + testCloudProviders.toArray(new String[0])); + + List<CloudProviderInfo> availableProviders = + CloudProviderUtils.getAllAvailableCloudProviders(isolatedContext, configStore); + + assertThat(availableProviders.size()).isEqualTo(testCloudProviders.size()); + for (CloudProviderInfo info : availableProviders) { + assertThat(testCloudProviders.contains(info.authority)).isTrue(); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/util/ThreadUtilsTest.java b/tests/src/com/android/providers/media/photopicker/util/ThreadUtilsTest.java new file mode 100644 index 000000000..676d8e5e1 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/util/ThreadUtilsTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 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.util; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static org.junit.Assert.assertThrows; + +import com.android.modules.utils.BackgroundThread; + +import org.junit.Test; + +public class ThreadUtilsTest { + @Test + public void testAssertMainThread_runOnMainThread() { + getInstrumentation().runOnMainSync(ThreadUtils::assertMainThread); + } + + @Test + public void testAssertMainThread_runOnNonMainThread() { + BackgroundThread.getExecutor().execute(() -> + assertThrows(IllegalStateException.class, ThreadUtils::assertMainThread)); + } + + @Test + public void testAssertNonMainThread_runOnMainThread() { + getInstrumentation().runOnMainSync(() -> + assertThrows(IllegalStateException.class, ThreadUtils::assertNonMainThread)); + } + + @Test + public void testAssertNonMainThread_runOnNonMainThread() { + BackgroundThread.getExecutor().execute(ThreadUtils::assertNonMainThread); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/BannerControllerTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/BannerControllerTest.java index a35155353..92894c242 100644 --- a/tests/src/com/android/providers/media/photopicker/viewmodel/BannerControllerTest.java +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/BannerControllerTest.java @@ -16,53 +16,63 @@ package com.android.providers.media.photopicker.viewmodel; -import static android.os.Process.myUserHandle; +import static android.provider.MediaStore.AUTHORITY; +import static android.provider.MediaStore.getCurrentCloudProvider; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static com.android.providers.media.photopicker.util.CloudProviderUtils.persistSelectedProvider; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import android.content.ContentProviderClient; +import android.content.ContentResolver; import android.content.Context; +import android.os.RemoteException; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.android.providers.media.IsolatedContext; +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.cloudproviders.FlakyCloudProvider; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class BannerControllerTest { - private BannerController mBannerController; + private static final Context sTargetContext = getInstrumentation().getTargetContext(); + private static final String TEST_PACKAGE_NAME = "com.android.providers.media.tests"; private static final String CMP_AUTHORITY = "authority"; private static final String CMP_ACCOUNT_NAME = "account_name"; + private IsolatedContext mIsolatedContext; + private ContentResolver mContentResolver; + private BannerController mBannerController; + @Before - public void setUp() { - final Context context = getInstrumentation().getTargetContext(); + public void setUp() throws RemoteException { + final TestConfigStore configStore = new TestConfigStore(); + configStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(TEST_PACKAGE_NAME); - mBannerController = new BannerController(context, myUserHandle()) { - @Override - void updateCloudProviderDataFile() { - // No-op - } + mIsolatedContext = new IsolatedContext(sTargetContext, /* tag= */ "databases", + /* asFuseThread= */ false, sTargetContext.getUser(), configStore); + mContentResolver = mIsolatedContext.getContentResolver(); - @Override - boolean areCloudProviderOptionsAvailable() { - return true; - } - }; + setCloudProvider(/* authority= */ null); + + mBannerController = BannerTestUtils.getTestBannerController( + mIsolatedContext, mIsolatedContext.getUser(), configStore); assertNull(mBannerController.getCloudMediaProviderAuthority()); assertNull(mBannerController.getCloudMediaProviderLabel()); assertNull(mBannerController.getCloudMediaProviderAccountName()); - - assertFalse(mBannerController.shouldShowCloudMediaAvailableBanner()); - assertFalse(mBannerController.shouldShowAccountUpdatedBanner()); - assertFalse(mBannerController.shouldShowChooseAccountBanner()); - assertFalse(mBannerController.shouldShowChooseAppBanner()); } @Test @@ -146,4 +156,51 @@ public class BannerControllerTest { assertFalse(mBannerController.shouldShowChooseAccountBanner()); assertFalse(mBannerController.shouldShowChooseAppBanner()); } + + @Test + public void testCloudProviderSlowQueryFallback() throws RemoteException { + setCloudProvider(FlakyCloudProvider.AUTHORITY); + + // Test for fast query + mIsolatedContext.resetFlakyCloudProviderToNotFlakeInTheNextRequest(); + mBannerController.onChangeCloudMediaInfo( + /* cmpAuthority */ null, /* cmpAccountName */ null); + mBannerController.reset(); + + assertEquals(FlakyCloudProvider.AUTHORITY, + mBannerController.getCloudMediaProviderAuthority()); + assertEquals(FlakyCloudProvider.ACCOUNT_NAME, + mBannerController.getCloudMediaProviderAccountName()); + + assertTrue(mBannerController.shouldShowCloudMediaAvailableBanner()); + assertFalse(mBannerController.shouldShowAccountUpdatedBanner()); + assertFalse(mBannerController.shouldShowChooseAccountBanner()); + assertFalse(mBannerController.shouldShowChooseAppBanner()); + + // Test for slow query + mIsolatedContext.setFlakyCloudProviderToFlakeInTheNextRequest(); + mBannerController.onChangeCloudMediaInfo( + /* cmpAuthority */ null, /* cmpAccountName */ null); + mBannerController.reset(); + + assertEquals(FlakyCloudProvider.AUTHORITY, + mBannerController.getCloudMediaProviderAuthority()); + assertNull(mBannerController.getCloudMediaProviderAccountName()); + + assertFalse(mBannerController.shouldShowCloudMediaAvailableBanner()); + assertFalse(mBannerController.shouldShowAccountUpdatedBanner()); + assertFalse(mBannerController.shouldShowChooseAccountBanner()); + assertFalse(mBannerController.shouldShowChooseAppBanner()); + } + + private void setCloudProvider(@Nullable String authority) throws RemoteException { + final ContentProviderClient client = + mContentResolver.acquireContentProviderClient(AUTHORITY); + assertNotNull(client); + + persistSelectedProvider(client, authority); + + final String actualAuthority = getCurrentCloudProvider(mContentResolver); + assertEquals(authority, actualAuthority); + } } diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/BannerTestUtils.java b/tests/src/com/android/providers/media/photopicker/viewmodel/BannerTestUtils.java new file mode 100644 index 000000000..4a8badf4b --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/BannerTestUtils.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 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.content.Context; +import android.os.UserHandle; + +import androidx.annotation.NonNull; + +import com.android.providers.media.ConfigStore; +import com.android.providers.media.photopicker.data.UserIdManager; + +class BannerTestUtils { + static BannerController getTestBannerController(@NonNull Context context, + @NonNull UserHandle userHandle, @NonNull ConfigStore configStore) { + return new BannerController(context, userHandle, configStore) { + @Override + void updateCloudProviderDataFile() { + // No-op + } + }; + } + + static BannerManager getTestCloudBannerManager(@NonNull Context context, + @NonNull UserIdManager userIdManager, @NonNull ConfigStore configStore) { + return new BannerManager.CloudBannerManager(context, userIdManager, configStore) { + @Override + void maybeInitialiseAndSetBannersForCurrentUser() { + // Get (iff exists) or create the banner controller for the current user + final BannerController bannerController = + getBannerControllersPerUser().forUser(getCurrentUserProfileId()); + // Post the banner related live data values from this current user banner controller + getCloudMediaProviderAuthorityLiveData() + .postValue(bannerController.getCloudMediaProviderAuthority()); + getCloudMediaProviderAppTitleLiveData() + .postValue(bannerController.getCloudMediaProviderLabel()); + getCloudMediaAccountNameLiveData() + .postValue(bannerController.getCloudMediaProviderAccountName()); + setChooseCloudMediaAccountActivityIntent( + bannerController.getChooseCloudMediaAccountActivityIntent()); + shouldShowChooseAppBannerLiveData() + .postValue(bannerController.shouldShowChooseAppBanner()); + shouldShowCloudMediaAvailableBannerLiveData() + .postValue(bannerController.shouldShowCloudMediaAvailableBanner()); + shouldShowAccountUpdatedBannerLiveData() + .postValue(bannerController.shouldShowAccountUpdatedBanner()); + shouldShowChooseAccountBannerLiveData() + .postValue(bannerController.shouldShowChooseAccountBanner()); + } + + @NonNull + @Override + BannerController createBannerController(@NonNull Context context, + @NonNull UserHandle userHandle, @NonNull ConfigStore configStore) { + return getTestBannerController(context, userHandle, configStore); + } + }; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/CategoryOrganiserTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/CategoryOrganiserTest.java new file mode 100644 index 000000000..3d6ecccf0 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/CategoryOrganiserTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2023 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 static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + +import static com.google.common.truth.Truth.assertThat; + +import android.provider.CloudMediaProviderContract; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.providers.media.photopicker.data.model.Category; +import com.android.providers.media.photopicker.util.CategoryOrganiserUtils; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +/** + * Unit test to ensure that the CategoryOrganiser reorders categories in the required way. + */ +@RunWith(AndroidJUnit4.class) +public class CategoryOrganiserTest { + + @Test + public void test_categoryOrder_meetsRequirements() { + List<Category> inputCategoryList = new ArrayList() { + { + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory1", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory2", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory3", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + } + }; + + // Expected list contains all the categories in the input list but in the required order, + // the order of custom categories is maintained. + List<Category> expectedCategoryList = new ArrayList() { + { + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory1", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory2", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + add(new Category( + "TestCategory3", + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true)); + } + }; + + // perform reorder. + CategoryOrganiserUtils.getReorganisedCategoryList(inputCategoryList); + + assertThat(inputCategoryList).isNotNull(); + assertThat(inputCategoryList.size()).isEqualTo(expectedCategoryList.size()); + for (int itr = 0; itr < inputCategoryList.size(); itr++) { + assertThat(inputCategoryList.get(itr).getId()).isEqualTo( + expectedCategoryList.get(itr).getId()); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java new file mode 100644 index 000000000..9e1ec5368 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java @@ -0,0 +1,524 @@ +/* + * Copyright (C) 2023 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 static android.provider.MediaStore.VOLUME_EXTERNAL; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_LOAD_NEXT_PAGE; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.Manifest; +import android.app.Application; +import android.app.Instrumentation; +import android.app.UiAutomation; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.CloudMediaProviderContract; +import android.provider.MediaStore; + +import androidx.lifecycle.LiveData; +import androidx.test.filters.SdkSuppress; +import androidx.test.runner.AndroidJUnit4; + +import com.android.providers.media.IsolatedContext; +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.photopicker.DataLoaderThread; +import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.PaginationParameters; +import com.android.providers.media.photopicker.data.UserIdManager; +import com.android.providers.media.photopicker.data.model.Category; +import com.android.providers.media.photopicker.data.model.Item; +import com.android.providers.media.photopicker.data.model.UserId; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class PickerViewModelPaginationTest { + + @Rule + public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); + + @Mock + private Application mApplication; + + private PickerViewModel mPickerViewModel; + + private static final Instrumentation sInstrumentation = getInstrumentation(); + private static final Context sTargetContext = sInstrumentation.getTargetContext(); + + private static final String TAG = "PickerViewModelTest"; + private ContentResolver mIsolatedResolver; + + public PickerViewModelPaginationTest() { + + } + + @Before + public void setUp() { + final UiAutomation uiAutomation = sInstrumentation.getUiAutomation(); + uiAutomation.adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.READ_DEVICE_CONFIG, + Manifest.permission.INTERACT_ACROSS_USERS); + MockitoAnnotations.initMocks(this); + + final TestConfigStore testConfigStore = new TestConfigStore(); + testConfigStore.enableCloudMediaFeature(); + + final Context isolatedContext = new IsolatedContext(sTargetContext, /* tag */ "databases", + /* asFuseThread */ false, sTargetContext.getUser(), testConfigStore); + when(mApplication.getApplicationContext()).thenReturn(isolatedContext); + sInstrumentation.runOnMainSync(() -> { + mPickerViewModel = new PickerViewModel(mApplication) { + @Override + protected void initConfigStore() { + setConfigStore(testConfigStore); + } + }; + }); + final UserIdManager userIdManager = mock(UserIdManager.class); + when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); + mPickerViewModel.setUserIdManager(userIdManager); + mIsolatedResolver = isolatedContext.getContentResolver(); + final ItemsProvider itemsProvider = new ItemsProvider(isolatedContext); + mPickerViewModel.setItemsProvider(itemsProvider); + mPickerViewModel.clearItemsAndCategoryItemsList(); + } + + @Test + public void test_getItems_noItemsPresent() throws Exception { + int pageSize = 4; + final int numberOfTestItems = 0; + try { + // Generate test items. + assertCreateNewImagesWithCategoryDownloads(numberOfTestItems); + + // Get live data for items, this also loads the first page. + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Empty list should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + + // Load next page size number of images. + mPickerViewModel.getPaginatedItemsForAction(ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Empty list should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + @Test + public void test_getCategoryItems_noItemsPresent() throws Exception { + int pageSize = 4; + final int numberOfTestItems = 0; + Category downloadsAlbum = new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true); + try { + // Generate test items. + assertCreateNewImagesWithCategoryDownloads( + numberOfTestItems); + + // Get live data for items, this also loads the first page. + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_VIEW_CREATED, + new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Empty list should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + + // Load next page size number of images. + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Empty list should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + @Test + public void test_getItems_correctItemsReturned() throws Exception { + int pageSize = 4; + final int numberOfTestItems = 10; + + try { + // Generate test items. + assertCreateNewImagesWithCategoryDownloads( + numberOfTestItems); + + // Get live data for items, this also loads the first page. + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Page 1: Since the page size is set to 4, only 4 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedItemsForAction(ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Page 2: 8 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(2 * pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedItemsForAction(ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Page 3: all 10 images should be returned. All items loaded. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + + // Try loading once more, but the number of images should not change since we have + // exhausted the list. + mPickerViewModel.getPaginatedItemsForAction(ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // All items loaded. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + + + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) + @Test + public void test_differentCategories_getCategoryItems() throws Exception { + int pageSize = 4; + final int numberOfTestItemsInDownloads = 10; + final int numberOfTestItemsInCamera = 7; + try { + // generate items in category downloads. + assertCreateNewImagesWithCategoryDownloads(numberOfTestItemsInDownloads); + + // generate items in category camera. + assertCreateNewImagesWithCategoryCamera(numberOfTestItemsInCamera); + + ////////////////// Verify Category Camera ////////////////// + + Category cameraAlbum = new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true); + + mPickerViewModel.initPhotoPickerData(cameraAlbum); + DataLoaderThread.waitForIdle(); + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedCategoryItemsForAction( + cameraAlbum, ACTION_VIEW_CREATED, + new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Page 1: Since the page size is set to 4, only 4 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedCategoryItemsForAction(cameraAlbum, + ACTION_LOAD_NEXT_PAGE, + null); + DataLoaderThread.waitForIdle(); + + // Page 2: 7 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItemsInCamera); + + // Try loading once more, but the number of images should not change since we have + // exhausted the list. + mPickerViewModel.getPaginatedCategoryItemsForAction(cameraAlbum, + ACTION_LOAD_NEXT_PAGE, + null); + DataLoaderThread.waitForIdle(); + + // All items loaded. + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItemsInCamera); + + ////////////////// Verify Category Downloads ////////////////// + + Category downloadsAlbum = new Category( + CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS, + LOCAL_PICKER_PROVIDER_AUTHORITY, "", null, 100, true); + + mPickerViewModel.initPhotoPickerData(downloadsAlbum); + DataLoaderThread.waitForIdle(); + LiveData<PickerViewModel.PaginatedItemsResult> testItemsDownloads = + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_VIEW_CREATED, + new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Page 1: Since the page size is set to 4, only 4 images should be returned. + assertThat(testItemsDownloads.getValue().getItems().size()).isEqualTo(pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Page 2: 8 images should be returned. + assertThat(testItemsDownloads.getValue().getItems().size()).isEqualTo(2 * pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Page 3: all 10 images should be returned. + assertThat(testItemsDownloads.getValue().getItems().size()).isEqualTo( + numberOfTestItemsInDownloads); + + + // Try loading once more, but the number of images should not change since we have + // exhausted the list. + mPickerViewModel.getPaginatedCategoryItemsForAction( + downloadsAlbum, ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // All items loaded. + assertThat(testItemsDownloads.getValue().getItems().size()).isEqualTo( + numberOfTestItemsInDownloads); + + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + @Test + public void test_updateItems_itemsResetAndFirstPageLoaded() throws Exception { + int pageSize = 4; + final int numberOfTestItems = 10; + + try { + // Generate test items. + assertCreateNewImagesWithCategoryDownloads( + numberOfTestItems); + + // Get live data for items, this also loads the first page. + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, new PaginationParameters(pageSize, + /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Page 1: Since the page size is set to 4, only 4 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + + // Load next page size number of images. + mPickerViewModel.getPaginatedItemsForAction(ACTION_LOAD_NEXT_PAGE, null); + DataLoaderThread.waitForIdle(); + + // Page 2: 8 images should be returned. + assertThat(testItems.getValue().getItems().size()).isEqualTo(2 * pageSize); + + // Now 8 items have been loaded in the item list. + // Call updateItems which is usually called on profile switch or reset. + // This should clear out the list and load the first page. + mPickerViewModel.getPaginatedItemsForAction(ACTION_CLEAR_AND_UPDATE_LIST, null); + DataLoaderThread.waitForIdle(); + + // Assert that only one page of items are present now. + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + + + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + @Test + public void test_onReceivingNotification_itemsRefreshed() throws Exception { + int pageSize = 10; + final int numberOfTestItems = 10; + + try { + // Generate test items. + assertCreateNewImagesWithCategoryDownloads( + numberOfTestItems); + + // Get live data for items, this also loads the first page. Here all 10 items will be + // loaded. + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, new PaginationParameters(pageSize, + /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + + // Store this values. + List<Item> previousList = testItems.getValue().getItems(); + + // add 2 new images. + assertCreateNewImagesWithCategoryDownloads(/* count of new items */ 2); + + mPickerViewModel.setNotificationForUpdateReceived(true); + + // Now 8 items have been loaded in the item list. + // Call updateItems which is usually called on profile switch or reset. + // This should clear out the list and load the first page. + mPickerViewModel.getPaginatedItemsForAction(ACTION_REFRESH_ITEMS, + new PaginationParameters( + pageSize, /*dateBeforeMs*/ Long.MIN_VALUE, /* rowId*/ -1)); + DataLoaderThread.waitForIdle(); + + // Assert that only one page of items are present now. + assertThat(testItems.getValue().getItems().size()).isEqualTo(pageSize); + List<Item> currentList = testItems.getValue().getItems(); + for (int itr = 0; itr < currentList.size(); itr++) { + assertThat(currentList.get(itr).compareTo(previousList.get(itr))).isNotEqualTo(0); + if (itr >= 2) { + // assert items have shifted by 2. + assertThat(currentList.get(itr).compareTo(previousList.get(itr - 2))).isEqualTo( + 0); + } + } + + + } finally { + mPickerViewModel.clearItemsAndCategoryItemsList(); + deleteAllFilesNoThrow(); + } + } + + private List<File> assertCreateNewImagesWithCategoryDownloads(int numberOfImages) + throws Exception { + List<File> imageFiles = new ArrayList<>(); + for (int itr = 0; itr < numberOfImages; itr++) { + String fileName = TAG + "_file_" + String.valueOf(System.nanoTime()) + ".jpg"; + imageFiles.add(assertCreateNewFileWithLastModifiedTime(getDownloadsDir(), fileName, + System.nanoTime() / 1000)); + } + return imageFiles; + } + + private List<File> assertCreateNewImagesWithCategoryCamera(int numberOfImages) + throws Exception { + List<File> imageFiles = new ArrayList<>(); + for (int itr = 0; itr < numberOfImages; itr++) { + String fileName = TAG + "_file_" + String.valueOf(System.nanoTime()) + ".jpg"; + imageFiles.add(assertCreateNewFileWithLastModifiedTime(getCameraDir(), fileName, + System.nanoTime() / 1000)); + } + return imageFiles; + } + + private File getDownloadsDir() { + return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS); + } + + private File getCameraDir() { + return new File(getDcimDir(), "Camera"); + } + + private File getDcimDir() { + return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DCIM); + } + + private File assertCreateNewFileWithLastModifiedTime(File parentDir, String fileName, + long lastModifiedTime) throws Exception { + final File file = new File(parentDir, fileName); + prepareFileAndGetUri(file, lastModifiedTime); + return file; + } + + private Uri prepareFileAndGetUri(File file, long lastModifiedTime) throws IOException { + ensureParentExists(file.getParentFile()); + + assertThat(file.createNewFile()).isTrue(); + + // Write 1 byte because 0byte files are not valid in the picker db + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(1); + } + + if (lastModifiedTime != -1) { + file.setLastModified(lastModifiedTime); + } + + final Uri uri = MediaStore.scanFile(mIsolatedResolver, file); + assertWithMessage("Uri obtained by scanning file " + file) + .that(uri).isNotNull(); + // Wait for picker db sync + MediaStore.waitForIdle(mIsolatedResolver); + + return uri; + } + + private void ensureParentExists(File parent) { + if (!parent.exists()) { + parent.mkdirs(); + } + assertThat(parent.exists()).isTrue(); + } + + private void deleteAllFilesNoThrow() { + try (Cursor c = mIsolatedResolver.query( + MediaStore.Files.getContentUri(VOLUME_EXTERNAL), + new String[]{MediaStore.MediaColumns.DATA}, null, null)) { + while (c.moveToNext()) { + (new File(c.getString( + c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)))).delete(); + } + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java index 2eb0aa1d1..4ca8dd954 100644 --- a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java @@ -17,37 +17,66 @@ package com.android.providers.media.photopicker.viewmodel; import static android.provider.CloudMediaProviderContract.AlbumColumns; -import static android.provider.CloudMediaProviderContract.MediaColumns; +import static android.provider.CloudMediaProviderContract.MediaColumns.AUTHORITY; +import static android.provider.CloudMediaProviderContract.MediaColumns.DATA; +import static android.provider.CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS; +import static android.provider.CloudMediaProviderContract.MediaColumns.DURATION_MILLIS; +import static android.provider.CloudMediaProviderContract.MediaColumns.HEIGHT; +import static android.provider.CloudMediaProviderContract.MediaColumns.ID; +import static android.provider.CloudMediaProviderContract.MediaColumns.IS_FAVORITE; +import static android.provider.CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI; +import static android.provider.CloudMediaProviderContract.MediaColumns.MIME_TYPE; +import static android.provider.CloudMediaProviderContract.MediaColumns.ORIENTATION; +import static android.provider.CloudMediaProviderContract.MediaColumns.SIZE_BYTES; +import static android.provider.CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION; +import static android.provider.CloudMediaProviderContract.MediaColumns.SYNC_GENERATION; +import static android.provider.CloudMediaProviderContract.MediaColumns.WIDTH; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.android.providers.media.PickerUriResolver.REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI; +import static com.android.providers.media.photopicker.data.model.Item.ROW_ID; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; +import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import android.app.Application; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.MatrixCursor; +import android.os.CancellationSignal; import android.provider.MediaStore; import android.text.format.DateUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; +import androidx.lifecycle.LiveData; +import androidx.test.filters.SdkSuppress; import androidx.test.runner.AndroidJUnit4; -import com.android.providers.media.ConfigStore; import com.android.providers.media.TestConfigStore; +import com.android.providers.media.photopicker.DataLoaderThread; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.PaginationParameters; +import com.android.providers.media.photopicker.data.Selection; import com.android.providers.media.photopicker.data.UserIdManager; 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.ModelTestUtils; import com.android.providers.media.photopicker.data.model.UserId; -import com.android.providers.media.util.ForegroundThread; import org.junit.Before; import org.junit.Rule; @@ -57,12 +86,19 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; @RunWith(AndroidJUnit4.class) public class PickerViewModelTest { private static final String FAKE_CATEGORY_NAME = "testCategoryName"; private static final String FAKE_ID = "5"; + private static final Context sTargetContext = getInstrumentation().getTargetContext(); + private static final String TEST_PACKAGE_NAME = "com.android.providers.media.tests"; + private static final String CMP_AUTHORITY = "authority"; + private static final String CMP_ACCOUNT_NAME = "account_name"; @Rule public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); @@ -73,40 +109,62 @@ public class PickerViewModelTest { private PickerViewModel mPickerViewModel; private TestItemsProvider mItemsProvider; private TestConfigStore mConfigStore; + private BannerManager mBannerManager; + private BannerController mBannerController; + + public PickerViewModelTest() { + } @Before public void setUp() { MockitoAnnotations.initMocks(this); - final Context context = InstrumentationRegistry.getTargetContext(); - when(mApplication.getApplicationContext()).thenReturn(context); + when(mApplication.getApplicationContext()).thenReturn(sTargetContext); mConfigStore = new TestConfigStore(); - mConfigStore.enableCloudMediaFeature(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages(TEST_PACKAGE_NAME); + mConfigStore.enablePickerChoiceManagedSelectionEnabled(); + + getInstrumentation().runOnMainSync(() -> { mPickerViewModel = new PickerViewModel(mApplication) { @Override - protected ConfigStore getConfigStore() { - return mConfigStore; + protected void initConfigStore() { + setConfigStore(mConfigStore); } }; }); - mItemsProvider = new TestItemsProvider(context); + mItemsProvider = new TestItemsProvider(sTargetContext); mPickerViewModel.setItemsProvider(mItemsProvider); - UserIdManager userIdManager = mock(UserIdManager.class); + final UserIdManager userIdManager = mock(UserIdManager.class); when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); mPickerViewModel.setUserIdManager(userIdManager); + + mBannerManager = BannerTestUtils.getTestCloudBannerManager(sTargetContext, userIdManager, + mConfigStore); + mPickerViewModel.setBannerManager(mBannerManager); + + // Set default banner manager values + mBannerController = mBannerManager.getBannerControllersPerUser().get( + UserId.CURRENT_USER.getIdentifier()); + assertNotNull(mBannerController); + mBannerController.onChangeCloudMediaInfo( + /* cmpAuthority= */ null, /* cmpAccountName= */ null); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); } @Test public void testGetItems_noItems() { final int itemCount = 0; mItemsProvider.setItems(generateFakeImageItemList(itemCount)); - mPickerViewModel.updateItems(); - // We use ForegroundThread to execute the loadItems in updateItems(), wait for the thread + mPickerViewModel.getPaginatedItemsForAction( + ACTION_CLEAR_AND_UPDATE_LIST, null); + // We use DataLoader thread to execute the loadItems in updateItems(), wait for the thread // idle - ForegroundThread.waitForIdle(); + DataLoaderThread.waitForIdle(); - final List<Item> itemList = mPickerViewModel.getItems().getValue(); + final List<Item> itemList = Objects.requireNonNull( + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, + new PaginationParameters()).getValue()).getItems(); // No date headers, the size should be 0 assertThat(itemList.size()).isEqualTo(itemCount); @@ -114,7 +172,6 @@ public class PickerViewModelTest { @Test public void testGetCategories() throws Exception { - final Context context = InstrumentationRegistry.getTargetContext(); final int categoryCount = 2; try (final Cursor fakeCursor = generateCursorForFakeCategories(categoryCount)) { fakeCursor.moveToFirst(); @@ -126,28 +183,90 @@ public class PickerViewModelTest { // move the cursor to original position fakeCursor.moveToPosition(-1); mPickerViewModel.updateCategories(); - // We use ForegroundThread to execute the loadCategories in updateCategories(), wait for + // We use DataLoaderThread to execute the loadCategories in updateCategories(), wait for // the thread idle - ForegroundThread.waitForIdle(); + DataLoaderThread.waitForIdle(); final List<Category> categoryList = mPickerViewModel.getCategories().getValue(); assertThat(categoryList.size()).isEqualTo(categoryCount); // Verify the first category final Category firstCategory = categoryList.get(0); - assertThat(firstCategory.getDisplayName(context)).isEqualTo( - fakeFirstCategory.getDisplayName(context)); + assertThat(firstCategory.getDisplayName(sTargetContext)).isEqualTo( + fakeFirstCategory.getDisplayName(sTargetContext)); assertThat(firstCategory.getItemCount()).isEqualTo(fakeFirstCategory.getItemCount()); assertThat(firstCategory.getCoverUri()).isEqualTo(fakeFirstCategory.getCoverUri()); // Verify the second category final Category secondCategory = categoryList.get(1); - assertThat(secondCategory.getDisplayName(context)).isEqualTo( - fakeSecondCategory.getDisplayName(context)); + assertThat(secondCategory.getDisplayName(sTargetContext)).isEqualTo( + fakeSecondCategory.getDisplayName(sTargetContext)); assertThat(secondCategory.getItemCount()).isEqualTo(fakeSecondCategory.getItemCount()); assertThat(secondCategory.getCoverUri()).isEqualTo(fakeSecondCategory.getCoverUri()); } } + @Test + public void test_getItems_correctItemsReturned() { + final int numberOfTestItems = 4; + final List<Item> expectedItems = generateFakeImageItemList(numberOfTestItems); + mItemsProvider.setItems(expectedItems); + + LiveData<PickerViewModel.PaginatedItemsResult> testItems = + mPickerViewModel.getPaginatedItemsForAction( + ACTION_VIEW_CREATED, + new PaginationParameters()); + DataLoaderThread.waitForIdle(); + + assertThat(testItems).isNotNull(); + assertThat(testItems.getValue()).isNotNull(); + assertThat(testItems.getValue().getItems().size()).isEqualTo(numberOfTestItems); + + for (int itr = 0; itr < numberOfTestItems; itr++) { + // Assert that all test and expected items are equal. + assertThat(testItems.getValue().getItems().get(itr).compareTo( + expectedItems.get(itr))).isEqualTo(0); + } + } + + @SdkSuppress(minSdkVersion = 34, codeName = "UpsideDownCake") + @Test + public void test_getRemainingPreGrantedItems_correctItemsLoaded() { + // Enable managed selection for this test. + Intent intent = new Intent(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP); + intent.putExtra(Intent.EXTRA_UID, 0); + mPickerViewModel.parseValuesFromIntent(intent); + + final int numberOfTestItems = 4; + final List<Item> expectedItems = generateFakeImageItemList(numberOfTestItems); + for (Item item : expectedItems) { + item.setPreGranted(); + } + mItemsProvider.setItems(expectedItems); + List<String> preGrantedItems = List.of(expectedItems.get(0).getId(), + expectedItems.get(1).getId(), + expectedItems.get(2).getId()); + Selection selection = mPickerViewModel.getSelection(); + // Add 3 item ids is preGranted set. + selection.setPreGrantedItemSet(new HashSet<>(preGrantedItems)); + + // adding 1 item in selection item set. + selection.addSelectedItem(expectedItems.get(1)); + + // revoking grant for 1 id. + selection.removeSelectedItem(expectedItems.get(0)); + + // since only one item is added in selection set, the size should be one. + assertThat(selection.getSelectedItems().size()).isEqualTo(1); + + // Since out of 3 one grant was removed, so there would be one item loaded when remaining + // grants are loaded. + mPickerViewModel.getRemainingPreGrantedItems(); + DataLoaderThread.waitForIdle(); + + // Now the selection set should have 2 items. + assertThat(selection.getSelectedItems().size()).isEqualTo(2); + } + private static Item generateFakeImageItem(String id) { final long dateTakenMs = System.currentTimeMillis() + Long.parseLong(id) * DateUtils.DAY_IN_MILLIS; @@ -173,7 +292,7 @@ public class PickerViewModelTest { FAKE_ID + String.valueOf(i), itemCount + i, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY - }); + }); } return cursor; } @@ -188,14 +307,89 @@ public class PickerViewModelTest { } @Override - public Cursor getAllItems(Category category, int limit, @Nullable String[] mimeType, - @Nullable UserId userId) throws + public Cursor getAllItems(Category category, + PaginationParameters paginationParameters, @Nullable String[] mimeType, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException, IllegalStateException { - final MatrixCursor c = new MatrixCursor(MediaColumns.ALL_PROJECTION); + final String[] all_projection = new String[]{ + ID, + // This field is unique to the cursor received by the pickerVIewModel. + // It is not a part of cloud provider contract. + ROW_ID, + DATE_TAKEN_MILLIS, + SYNC_GENERATION, + MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, + SIZE_BYTES, + MEDIA_STORE_URI, + DURATION_MILLIS, + IS_FAVORITE, + WIDTH, + HEIGHT, + ORIENTATION, + DATA, + AUTHORITY, + }; + final MatrixCursor c = new MatrixCursor(all_projection); + + int itr = 1; + for (Item item : mItemList) { + c.addRow(new String[]{ + item.getId(), + String.valueOf(itr), + String.valueOf(item.getDateTaken()), + String.valueOf(item.getGenerationModified()), + item.getMimeType(), + String.valueOf(item.getSpecialFormat()), + "1", // size_bytes + null, // media_store_uri + String.valueOf(item.getDuration()), + "0", // is_favorite + String.valueOf(800), // width + String.valueOf(500), // height + String.valueOf(0), // orientation + "/storage/emulated/0/foo", + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY + }); + itr++; + } + + return c; + } + + @Override + public Cursor getLocalItems(Category category, + PaginationParameters paginationParameters, @Nullable String[] mimeType, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws + IllegalArgumentException, IllegalStateException { + final String[] all_projection = new String[]{ + ID, + // This field is unique to the cursor received by the pickerVIewModel. + // It is not a part of cloud provider contract. + ROW_ID, + DATE_TAKEN_MILLIS, + SYNC_GENERATION, + MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, + SIZE_BYTES, + MEDIA_STORE_URI, + DURATION_MILLIS, + IS_FAVORITE, + WIDTH, + HEIGHT, + ORIENTATION, + DATA, + AUTHORITY, + }; + final MatrixCursor c = new MatrixCursor(all_projection); + int itr = 1; for (Item item : mItemList) { - c.addRow(new String[] { + c.addRow(new String[]{ item.getId(), + String.valueOf(itr), String.valueOf(item.getDateTaken()), String.valueOf(item.getGenerationModified()), item.getMimeType(), @@ -204,16 +398,75 @@ public class PickerViewModelTest { null, // media_store_uri String.valueOf(item.getDuration()), "0", // is_favorite + String.valueOf(800), // width + String.valueOf(500), // height + String.valueOf(0), // orientation "/storage/emulated/0/foo", PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY }); + itr++; } return c; } + @Override + public Cursor getLocalItemsForSelection(Category category, + @NonNull List<Integer> localIdSelection, + @Nullable String[] mimeTypes, + @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { + final String[] all_projection = new String[]{ + ID, + // This field is unique to the cursor received by the pickerVIewModel. + // It is not a part of cloud provider contract. + ROW_ID, + DATE_TAKEN_MILLIS, + SYNC_GENERATION, + MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, + SIZE_BYTES, + MEDIA_STORE_URI, + DURATION_MILLIS, + IS_FAVORITE, + WIDTH, + HEIGHT, + ORIENTATION, + DATA, + AUTHORITY, + }; + final MatrixCursor c = new MatrixCursor(all_projection); + + int itr = 1; + for (Item item : mItemList) { + if (localIdSelection.contains(Integer.parseInt(item.getId()))) { + c.addRow(new String[]{ + item.getId(), + String.valueOf(itr), + String.valueOf(item.getDateTaken()), + String.valueOf(item.getGenerationModified()), + item.getMimeType(), + String.valueOf(item.getSpecialFormat()), + "1", // size_bytes + null, // media_store_uri + String.valueOf(item.getDuration()), + "0", // is_favorite + String.valueOf(800), // width + String.valueOf(500), // height + String.valueOf(0), // orientation + "/storage/emulated/0/foo", + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY + }); + itr++; + } + } + return c; + + } + @Nullable - public Cursor getAllCategories(@Nullable String[] mimeType, @Nullable UserId userId) { + public Cursor getAllCategories(@Nullable String[] mimeType, @Nullable UserId userId, + @Nullable CancellationSignal cancellationSignal) { if (mCategoriesCursor != null) { return mCategoriesCursor; } @@ -263,7 +516,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromPickImagesIntent_validExtraMimeType() { final Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"image/gif", "video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/gif", "video/*"}); mPickerViewModel.parseValuesFromIntent(intent); @@ -273,7 +526,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromPickImagesIntent_invalidExtraMimeType() { final Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"audio/*", "video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"audio/*", "video/*"}); try { mPickerViewModel.parseValuesFromIntent(intent); @@ -305,7 +558,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromGetContentIntent_validExtraMimeType() { final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"image/gif", "video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/gif", "video/*"}); mPickerViewModel.parseValuesFromIntent(intent); @@ -315,7 +568,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromGetContentIntent_invalidExtraMimeType() { final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"audio/*", "video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"audio/*", "video/*"}); mPickerViewModel.parseValuesFromIntent(intent); @@ -326,7 +579,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromGetContentIntent_localOnlyTrue() { final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"video/*"}); intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); mPickerViewModel.parseValuesFromIntent(intent); @@ -337,7 +590,7 @@ public class PickerViewModelTest { @Test public void testParseValuesFromGetContentIntent_localOnlyFalse() { final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"video/*"}); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"video/*"}); mPickerViewModel.parseValuesFromIntent(intent); @@ -362,4 +615,123 @@ public class PickerViewModelTest { mConfigStore.disableCloudMediaFeature(); assertThat(mPickerViewModel.shouldShowOnlyLocalFeatures()).isTrue(); } + + @Test + public void testRefreshUiNotifications() throws InterruptedException { + final LiveData<Boolean> shouldRefreshUi = mPickerViewModel.shouldRefreshUiLiveData(); + assertFalse(shouldRefreshUi.getValue()); + + final ContentResolver contentResolver = sTargetContext.getContentResolver(); + contentResolver.notifyChange(REFRESH_UI_PICKER_INTERNAL_OBSERVABLE_URI, null); + + TimeUnit.MILLISECONDS.sleep(100); + assertTrue(shouldRefreshUi.getValue()); + + mPickerViewModel.resetAllContentInCurrentProfile(); + assertFalse(shouldRefreshUi.getValue()); + } + + @Test + public void testDismissChooseAppBanner() { + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, CMP_ACCOUNT_NAME); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + + mBannerController.onChangeCloudMediaInfo( + /* cmpAuthority= */ null, /* cmpAccountName= */ null); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + assertTrue(mBannerController.shouldShowChooseAppBanner()); + assertTrue(mPickerViewModel.shouldShowChooseAppBannerLiveData().getValue()); + + getInstrumentation().runOnMainSync(() -> mPickerViewModel.onUserDismissedChooseAppBanner()); + assertFalse(mBannerController.shouldShowChooseAppBanner()); + assertFalse(mPickerViewModel.shouldShowChooseAppBannerLiveData().getValue()); + + // Assert no change on dismiss when the banner is already hidden + getInstrumentation().runOnMainSync(() -> mPickerViewModel.onUserDismissedChooseAppBanner()); + assertFalse(mBannerController.shouldShowChooseAppBanner()); + assertFalse(mPickerViewModel.shouldShowChooseAppBannerLiveData().getValue()); + } + + @Test + public void testDismissCloudMediaAvailableBanner() { + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, CMP_ACCOUNT_NAME); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + assertTrue(mBannerController.shouldShowCloudMediaAvailableBanner()); + assertTrue(mPickerViewModel.shouldShowCloudMediaAvailableBannerLiveData().getValue()); + + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedCloudMediaAvailableBanner()); + assertFalse(mBannerController.shouldShowCloudMediaAvailableBanner()); + assertFalse(mPickerViewModel.shouldShowCloudMediaAvailableBannerLiveData().getValue()); + + // Assert no change on dismiss when the banner is already hidden + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedCloudMediaAvailableBanner()); + assertFalse(mBannerController.shouldShowCloudMediaAvailableBanner()); + assertFalse(mPickerViewModel.shouldShowCloudMediaAvailableBannerLiveData().getValue()); + } + + @Test + public void testDismissAccountUpdatedBanner() { + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, /* cmpAccountName= */ null); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, CMP_ACCOUNT_NAME); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + assertTrue(mBannerController.shouldShowAccountUpdatedBanner()); + assertTrue(mPickerViewModel.shouldShowAccountUpdatedBannerLiveData().getValue()); + + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedAccountUpdatedBanner()); + assertFalse(mBannerController.shouldShowAccountUpdatedBanner()); + assertFalse(mPickerViewModel.shouldShowAccountUpdatedBannerLiveData().getValue()); + + // Assert no change on dismiss when the banner is already hidden + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedAccountUpdatedBanner()); + assertFalse(mBannerController.shouldShowAccountUpdatedBanner()); + assertFalse(mPickerViewModel.shouldShowAccountUpdatedBannerLiveData().getValue()); + } + + @Test + public void testDismissChooseAccountBanner() { + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, /* cmpAccountName= */ null); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + assertTrue(mBannerController.shouldShowChooseAccountBanner()); + assertTrue(mPickerViewModel.shouldShowChooseAccountBannerLiveData().getValue()); + + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedChooseAccountBanner()); + assertFalse(mBannerController.shouldShowChooseAccountBanner()); + assertFalse(mPickerViewModel.shouldShowChooseAccountBannerLiveData().getValue()); + + // Assert no change on dismiss when the banner is already hidden + getInstrumentation().runOnMainSync(() -> + mPickerViewModel.onUserDismissedChooseAccountBanner()); + assertFalse(mBannerController.shouldShowChooseAccountBanner()); + assertFalse(mPickerViewModel.shouldShowChooseAccountBannerLiveData().getValue()); + } + + @Test + public void testGetCloudMediaProviderAuthorityLiveData() { + assertNull(mPickerViewModel.getCloudMediaProviderAuthorityLiveData().getValue()); + + mBannerController.onChangeCloudMediaInfo(CMP_AUTHORITY, /* cmpAccountName= */ null); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + + assertEquals(CMP_AUTHORITY, + mPickerViewModel.getCloudMediaProviderAuthorityLiveData().getValue()); + } + + @Test + public void testGetChooseCloudMediaAccountActivityIntent() { + assertNull(mPickerViewModel.getChooseCloudMediaAccountActivityIntent()); + + final Intent testIntent = new Intent(); + mBannerController.setChooseCloudMediaAccountActivityIntent(testIntent); + mBannerManager.maybeInitialiseAndSetBannersForCurrentUser(); + + assertEquals(testIntent, + mPickerViewModel.getChooseCloudMediaAccountActivityIntent()); + } } diff --git a/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java b/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java index 2831e963e..0902ea5e8 100644 --- a/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java +++ b/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java @@ -19,16 +19,17 @@ package com.android.providers.media.scan; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; -import android.provider.MediaStore; - import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.providers.media.library.RunOnlyOnPostsubmit; + import org.junit.Test; import org.junit.runner.RunWith; import java.io.File; +@RunOnlyOnPostsubmit @RunWith(AndroidJUnit4.class) public class LegacyMediaScannerTest { @Test diff --git a/tests/src/com/android/providers/media/scan/NullMediaScannerTest.java b/tests/src/com/android/providers/media/scan/NullMediaScannerTest.java deleted file mode 100644 index 265d1a97a..000000000 --- a/tests/src/com/android/providers/media/scan/NullMediaScannerTest.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.providers.media.scan; - -import static org.junit.Assert.assertNotNull; - -import android.provider.MediaStore; - -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; - -@RunWith(AndroidJUnit4.class) -public class NullMediaScannerTest { - @Test - public void testSimple() throws Exception { - final NullMediaScanner scanner = new NullMediaScanner( - InstrumentationRegistry.getTargetContext()); - assertNotNull(scanner.getContext()); - - scanner.scanDirectory(new File("/dev/null"), MediaScanner.REASON_UNKNOWN); - scanner.scanFile(new File("/dev/null"), MediaScanner.REASON_UNKNOWN); - - scanner.onDetachVolume(null); - } -} diff --git a/tests/src/com/android/providers/media/stableuris/dao/BackupIdRowTest.java b/tests/src/com/android/providers/media/stableuris/dao/BackupIdRowTest.java index bfadf87af..f0087355b 100644 --- a/tests/src/com/android/providers/media/stableuris/dao/BackupIdRowTest.java +++ b/tests/src/com/android/providers/media/stableuris/dao/BackupIdRowTest.java @@ -44,8 +44,9 @@ public class BackupIdRowTest { .setIsTrashed(0) .setOwnerPackagedId(1) .setUserId(1) - .setDateExpires("10") + .setDateExpires(null) .setIsDirty(true) + .setMediaType(1) .build(); String s = BackupIdRow.serialize(row); @@ -59,6 +60,7 @@ public class BackupIdRowTest { .setUserId(1) .setDateExpires("10") .setIsDirty(false) + .setMediaType(0) .build(); assertThat(BackupIdRow.deserialize(s)).isNotEqualTo(row2); diff --git a/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java b/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java index 9d29f118a..735230911 100644 --- a/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java +++ b/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java @@ -16,14 +16,26 @@ package com.android.providers.media.stableuris.job; +import static com.android.providers.media.tests.utils.PublicVolumeSetupHelper.createNewPublicVolume; +import static com.android.providers.media.tests.utils.PublicVolumeSetupHelper.deletePublicVolumes; +import static com.android.providers.media.util.FileUtils.getVolumePath; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import android.Manifest; +import android.app.job.JobScheduler; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; +import android.net.Uri; +import android.os.Environment; +import android.os.SystemClock; +import android.os.UserHandle; import android.provider.DeviceConfig; import android.provider.MediaStore; import android.util.Log; @@ -33,85 +45,282 @@ import androidx.test.filters.SdkSuppress; import androidx.test.runner.AndroidJUnit4; import com.android.providers.media.ConfigStore; +import com.android.providers.media.stableuris.dao.BackupIdRow; -import org.junit.After; -import org.junit.Before; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import java.io.IOException; +import java.io.File; +import java.io.FileOutputStream; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; @RunWith(AndroidJUnit4.class) +@SdkSuppress(minSdkVersion = 31, codeName = "S") public class StableUriIdleMaintenanceServiceTest { private static final String TAG = "StableUriIdleMaintenanceServiceTest"; private static final String INTERNAL_BACKUP_NAME = "leveldb-internal"; - private boolean mInitialDeviceConfigValue = false; + private static final String EXTERNAL_BACKUP_NAME = "leveldb-external_primary"; + + private static final String OWNERSHIP_BACKUP_NAME = "leveldb-ownership"; + + private static final String PUBLIC_VOLUME_BACKUP_NAME = "leveldb-"; + + private static boolean sInitialDeviceConfigValueForInternal = false; + + private static boolean sInitialDeviceConfigValueForExternal = false; + + private static boolean sInitialDeviceConfigValueForPublic = false; + + private static final int IDLE_JOB_ID = -500; + + @BeforeClass + public static void setUpClass() throws Exception { + adoptShellPermission(); - @Before - public void setUp() throws IOException { - InstrumentationRegistry.getInstrumentation().getUiAutomation() - .adoptShellPermissionIdentity(android.Manifest.permission.LOG_COMPAT_CHANGE, - android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG, - android.Manifest.permission.READ_DEVICE_CONFIG, - android.Manifest.permission.WRITE_DEVICE_CONFIG, - Manifest.permission.WRITE_MEDIA_STORAGE); // Read existing value of the flag - mInitialDeviceConfigValue = Boolean.parseBoolean( - DeviceConfig.getProperty(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, - ConfigStore.ConfigStoreImpl.KEY_STABILISE_VOLUME_INTERNAL)); - DeviceConfig.setProperty(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, - ConfigStore.ConfigStoreImpl.KEY_STABILISE_VOLUME_INTERNAL, Boolean.TRUE.toString(), + sInitialDeviceConfigValueForInternal = Boolean.parseBoolean( + DeviceConfig.getProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_INTERNAL)); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_INTERNAL, Boolean.TRUE.toString(), + false); + sInitialDeviceConfigValueForExternal = Boolean.parseBoolean( + DeviceConfig.getProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_EXTERNAL)); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_EXTERNAL, Boolean.TRUE.toString(), + false); + sInitialDeviceConfigValueForPublic = Boolean.parseBoolean( + DeviceConfig.getProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_PUBLIC)); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_PUBLIC, Boolean.TRUE.toString(), false); } - @After - public void tearDown() throws IOException { + @AfterClass + public static void tearDownClass() throws Exception { + // Restore previous value of the flag - DeviceConfig.setProperty(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, - ConfigStore.ConfigStoreImpl.KEY_STABILISE_VOLUME_INTERNAL, - String.valueOf(mInitialDeviceConfigValue), false); - InstrumentationRegistry.getInstrumentation() - .getUiAutomation().dropShellPermissionIdentity(); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_INTERNAL, + String.valueOf(sInitialDeviceConfigValueForInternal), false); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_EXTERNAL, + String.valueOf(sInitialDeviceConfigValueForExternal), false); + DeviceConfig.setProperty(ConfigStore.NAMESPACE_MEDIAPROVIDER, + ConfigStore.ConfigStoreImpl.KEY_STABILIZE_VOLUME_PUBLIC, + String.valueOf(sInitialDeviceConfigValueForPublic), false); + SystemClock.sleep(3000); + dropShellPermission(); } @Test - @SdkSuppress(minSdkVersion = 31, codeName = "S") - public void testDataMigrationForInternalVolume() { + public void testDataMigrationForInternalVolume() throws Exception { final Context context = InstrumentationRegistry.getTargetContext(); final ContentResolver resolver = context.getContentResolver(); - Set<String> internalFiles = new HashSet<>(); + Set<String> internalFilePaths = new HashSet<>(); + Map<String, Long> pathToIdMap = new HashMap<>(); MediaStore.waitForIdle(resolver); try (Cursor c = resolver.query(MediaStore.Files.getContentUri(MediaStore.VOLUME_INTERNAL), - new String[]{MediaStore.Files.FileColumns.DATA}, null, null)) { + new String[]{MediaStore.Files.FileColumns.DATA, MediaStore.Files.FileColumns._ID}, + null, null)) { assertNotNull(c); while (c.moveToNext()) { String path = c.getString(0); - internalFiles.add(path); + internalFilePaths.add(path); + pathToIdMap.put(path, c.getLong(1)); } } - assertFalse(internalFiles.isEmpty()); - // Delete any existing backup to confirm that backup created is by idle maintenance job - MediaStore.deleteBackedUpFilePaths(resolver, MediaStore.VOLUME_INTERNAL); + assertFalse(internalFilePaths.isEmpty()); MediaStore.waitForIdle(resolver); // Creates backup MediaStore.runIdleMaintenanceForStableUris(resolver); - List<String> backedUpFiles = Arrays.asList(MediaStore.getBackupFiles(resolver)); - assertTrue(backedUpFiles.contains(INTERNAL_BACKUP_NAME)); - // Read all backed up paths - List<String> backedUpPaths = Arrays.asList( - MediaStore.readBackedUpFilePaths(resolver, MediaStore.VOLUME_INTERNAL)); - Log.i(TAG, "BackedUpPaths count:" + backedUpPaths.size()); + verifyLevelDbPresence(resolver, INTERNAL_BACKUP_NAME); // Verify that all internal files are backed up - for (String path : internalFiles) { - assertTrue(backedUpPaths.contains(path)); + for (String path : internalFilePaths) { + BackupIdRow backupIdRow = BackupIdRow.deserialize(MediaStore.readBackup(resolver, + MediaStore.VOLUME_EXTERNAL_PRIMARY, path)); + assertNotNull(backupIdRow); + assertEquals(pathToIdMap.get(path).longValue(), backupIdRow.getId()); + assertEquals(UserHandle.myUserId(), backupIdRow.getUserId()); + } + } + + @Test + public void testDataMigrationForExternalVolume() throws Exception { + final Context context = InstrumentationRegistry.getTargetContext(); + final ContentResolver resolver = context.getContentResolver(); + Set<String> newFilePaths = new HashSet<String>(); + Map<String, Long> pathToIdMap = new HashMap<>(); + MediaStore.waitForIdle(resolver); + + try { + for (int i = 0; i < 10; i++) { + final File dir = + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + final File file = new File(dir, System.nanoTime() + ".png"); + + // Write 1 byte because 0 byte files are not valid in the db + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(1); + } + + Uri uri = MediaStore.scanFile(resolver, file); + long id = ContentUris.parseId(uri); + newFilePaths.add(file.getAbsolutePath()); + pathToIdMap.put(file.getAbsolutePath(), id); + } + + assertFalse(newFilePaths.isEmpty()); + MediaStore.waitForIdle(resolver); + // Creates backup + MediaStore.runIdleMaintenanceForStableUris(resolver); + + verifyLevelDbPresence(resolver, EXTERNAL_BACKUP_NAME); + verifyLevelDbPresence(resolver, OWNERSHIP_BACKUP_NAME); + // Verify that all internal files are backed up + for (String filePath : newFilePaths) { + BackupIdRow backupIdRow = BackupIdRow.deserialize( + MediaStore.readBackup(resolver, MediaStore.VOLUME_EXTERNAL_PRIMARY, + filePath)); + Log.i(TAG, "BackupIdRow is " + backupIdRow); + assertNotNull(backupIdRow); + assertEquals(pathToIdMap.get(filePath).longValue(), backupIdRow.getId()); + assertEquals(UserHandle.myUserId(), backupIdRow.getUserId()); + assertEquals(context.getPackageName(), + MediaStore.getOwnerPackageName(resolver, backupIdRow.getOwnerPackageId())); + } + } finally { + for (String path : newFilePaths) { + new File(path).delete(); + } + } + } + + @Test + @Ignore + public void testDataMigrationForPublicVolume() throws Exception { + createNewPublicVolume(); + try { + final Context context = InstrumentationRegistry.getTargetContext(); + final ContentResolver resolver = context.getContentResolver(); + final Set<String> volNames = MediaStore.getExternalVolumeNames(context); + + for (String volName : volNames) { + if (!MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volName) + && !MediaStore.VOLUME_INTERNAL.equalsIgnoreCase(volName)) { + // public volume + Set<String> newFilePaths = new HashSet<String>(); + Map<String, Long> pathToIdMap = new HashMap<>(); + MediaStore.waitForIdle(resolver); + + try { + for (int i = 0; i < 10; i++) { + File volPath = getVolumePath(context, volName); + final File dir = new File(volPath.getAbsoluteFile() + "/Download"); + final File file = new File(dir, System.nanoTime() + ".png"); + + // Write 1 byte because 0 byte files are not valid in the db + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(1); + } + + Uri uri = MediaStore.scanFile(resolver, file); + long id = ContentUris.parseId(uri); + newFilePaths.add(file.getAbsolutePath()); + pathToIdMap.put(file.getAbsolutePath(), id); + } + + assertFalse(newFilePaths.isEmpty()); + MediaStore.waitForIdle(resolver); + // Creates backup + MediaStore.runIdleMaintenanceForStableUris(resolver); + + verifyLevelDbPresence(resolver, PUBLIC_VOLUME_BACKUP_NAME + volName); + verifyLevelDbPresence(resolver, OWNERSHIP_BACKUP_NAME); + // Verify that all internal files are backed up + for (String filePath : newFilePaths) { + BackupIdRow backupIdRow = BackupIdRow.deserialize( + MediaStore.readBackup(resolver, volName, filePath)); + assertNotNull(backupIdRow); + assertEquals(pathToIdMap.get(filePath).longValue(), + backupIdRow.getId()); + assertEquals(UserHandle.myUserId(), backupIdRow.getUserId()); + assertEquals(context.getPackageName(), + MediaStore.getOwnerPackageName(resolver, + backupIdRow.getOwnerPackageId())); + } + } finally { + for (String path : newFilePaths) { + new File(path).delete(); + } + } + } + } + } finally { + deletePublicVolumes(); + } + } + + @Test + public void testJobScheduling() { + try { + final Context context = InstrumentationRegistry.getTargetContext(); + final JobScheduler scheduler = InstrumentationRegistry.getTargetContext() + .getSystemService(JobScheduler.class); + cancelJob(); + assertNull(scheduler.getPendingJob(IDLE_JOB_ID)); + + StableUriIdleMaintenanceService.scheduleIdlePass(context); + assertNotNull(scheduler.getPendingJob(IDLE_JOB_ID)); + } finally { + cancelJob(); + } + } + + private void verifyLevelDbPresence(ContentResolver resolver, String backupName) { + List<String> backedUpFiles = Arrays.asList(MediaStore.getBackupFiles(resolver)); + assertTrue(backedUpFiles.contains(backupName)); + } + + private static void adoptShellPermission() { + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity( + Manifest.permission.READ_DEVICE_CONFIG, + Manifest.permission.WRITE_DEVICE_CONFIG, + Manifest.permission.WRITE_MEDIA_STORAGE, + android.Manifest.permission.LOG_COMPAT_CHANGE, + android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.INTERACT_ACROSS_USERS, + android.Manifest.permission.DUMP); + SystemClock.sleep(3000); + } + + private static void dropShellPermission() { + InstrumentationRegistry.getInstrumentation() + .getUiAutomation().dropShellPermissionIdentity(); + } + + private void cancelJob() { + final JobScheduler scheduler = InstrumentationRegistry.getTargetContext() + .getSystemService(JobScheduler.class); + if (scheduler.getPendingJob(IDLE_JOB_ID) != null) { + scheduler.cancel(IDLE_JOB_ID); } } } diff --git a/tests/src/com/android/providers/media/util/FileCreationUtils.java b/tests/src/com/android/providers/media/util/FileCreationUtils.java index 4c4b2813c..02ead9c7f 100644 --- a/tests/src/com/android/providers/media/util/FileCreationUtils.java +++ b/tests/src/com/android/providers/media/util/FileCreationUtils.java @@ -33,18 +33,33 @@ import java.io.IOException; * A utility class to assist creating files for tests */ public class FileCreationUtils { + /** * Helper method to insert a test image/png into given {@code contentResolver} * - * @param contentResolver ContentResolver to which file is inserted - * @param name file name + * @param contentResolver ContentResolver to which file is inserted + * @param name file name * @return {@link Long} the files table {@link MediaStore.MediaColumns.ID} */ public static Long insertFileInResolver(ContentResolver contentResolver, String name) throws IOException { + return insertFileInResolver(contentResolver, name, "png"); + } + + /** + * Helper method to insert a test item into given {@code contentResolver} with the provided + * mimeType. + * + * @param contentResolver ContentResolver to which file is inserted + * @param name file name + * @return {@link Long} the files table {@link MediaStore.MediaColumns.ID} + */ + public static Long insertFileInResolver(ContentResolver contentResolver, String name, + String mimeType) + throws IOException { final File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - final File file = new File(dir, name + System.nanoTime() + ".png"); + final File file = new File(dir, name + System.nanoTime() + "." + mimeType); // Write 1 byte because 0 byte files are not valid in the db try (FileOutputStream fos = new FileOutputStream(file)) { diff --git a/tests/src/com/android/providers/media/util/FileUtilsTest.java b/tests/src/com/android/providers/media/util/FileUtilsTest.java index eeb7054d4..7c63807e5 100644 --- a/tests/src/com/android/providers/media/util/FileUtilsTest.java +++ b/tests/src/com/android/providers/media/util/FileUtilsTest.java @@ -1006,6 +1006,10 @@ public class FileUtilsTest { // Marking as dirty with a .nomedia file works FileUtils.setDirectoryDirty(dirInDownload, true); assertTrue(FileUtils.isDirectoryDirty(dirInDownload)); + + // Test case-insensitivity + File dirInDownloadDifferentCase = new File(mTestDownloadDir, "TeStDirEctoRYdirTy"); + assertTrue(FileUtils.isDirectoryDirty(dirInDownloadDifferentCase)); } @Test diff --git a/tests/src/com/android/providers/media/util/PermissionUtilsTest.java b/tests/src/com/android/providers/media/util/PermissionUtilsTest.java index be66c0973..661fd625e 100644 --- a/tests/src/com/android/providers/media/util/PermissionUtilsTest.java +++ b/tests/src/com/android/providers/media/util/PermissionUtilsTest.java @@ -66,6 +66,7 @@ import android.app.AppOpsManager; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; +import android.os.SystemClock; import androidx.test.filters.SdkSuppress; import androidx.test.runner.AndroidJUnit4; @@ -460,6 +461,8 @@ public class PermissionUtilsTest { assertThat(checkPermissionReadVideo(getContext(), TEST_APP_PID, testAppUid, packageName, null, isAtLeastT)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_VIDEO, AppOpsManager.MODE_ALLOWED); + // Adding sleep before appops check to allow appops change to propagate + SystemClock.sleep(200); assertThat(checkPermissionReadVideo(getContext(), TEST_APP_PID, testAppUid, packageName, null, isAtLeastT)).isTrue(); } finally { @@ -518,6 +521,8 @@ public class PermissionUtilsTest { packageName, null, isAtLeastT)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_AUDIO, AppOpsManager.MODE_ALLOWED); + // Adding sleep before appops check to allow appops change to propagate + SystemClock.sleep(200); assertThat(checkPermissionReadAudio(getContext(), TEST_APP_PID, testAppUid, packageName, null, isAtLeastT)).isTrue(); } finally { @@ -550,6 +555,8 @@ public class PermissionUtilsTest { packageName, null, isAtLeastT)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_IMAGES, AppOpsManager.MODE_ALLOWED); + // Adding sleep before appops check to allow appops change to propagate + SystemClock.sleep(200); assertThat(checkPermissionReadImages(getContext(), TEST_APP_PID, testAppUid, packageName, null, isAtLeastT)).isTrue(); } finally { diff --git a/tools/photopicker/res/layout/activity_main.xml b/tools/photopicker/res/layout/activity_main.xml index 441cd0fc0..6348a4e2c 100644 --- a/tools/photopicker/res/layout/activity_main.xml +++ b/tools/photopicker/res/layout/activity_main.xml @@ -100,6 +100,13 @@ android:textSize="16sp" /> </LinearLayout> + <CheckBox + android:id="@+id/cbx_ordered_selection" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="ORDERED SELECTION" + android:textSize="16sp" /> + <Button android:id="@+id/launch_button" android:layout_width="match_parent" diff --git a/tools/photopicker/src/com/android/providers/media/tools/photopicker/PhotoPickerToolActivity.java b/tools/photopicker/src/com/android/providers/media/tools/photopicker/PhotoPickerToolActivity.java index fc91077ab..1d549f34f 100644 --- a/tools/photopicker/src/com/android/providers/media/tools/photopicker/PhotoPickerToolActivity.java +++ b/tools/photopicker/src/com/android/providers/media/tools/photopicker/PhotoPickerToolActivity.java @@ -63,6 +63,7 @@ public class PhotoPickerToolActivity extends Activity { private CheckBox mSetSelectionCountCheckBox; private CheckBox mAllowMultipleCheckBox; private CheckBox mGetContentCheckBox; + private CheckBox mOrderedSelectionCheckBox; private EditText mMaxCountText; private EditText mMimeTypeText; @@ -77,6 +78,7 @@ public class PhotoPickerToolActivity extends Activity { mSetMimeTypeCheckBox = findViewById(R.id.cbx_set_mime_type); mSetSelectionCountCheckBox = findViewById(R.id.cbx_set_selection_count); mSetVideoOnlyCheckBox = findViewById(R.id.cbx_set_video_only); + mOrderedSelectionCheckBox = findViewById(R.id.cbx_ordered_selection); mMaxCountText = findViewById(R.id.edittext_max_count); mMimeTypeText = findViewById(R.id.edittext_mime_type); mScrollView = findViewById(R.id.scrollview); @@ -169,6 +171,10 @@ public class PhotoPickerToolActivity extends Activity { intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); } else { intent.putExtra(EXTRA_PICK_IMAGES_MAX, PICK_IMAGES_MAX_LIMIT); + // ordered selection is not allowed in get content. + if (mOrderedSelectionCheckBox.isChecked()) { + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, true); + } } } |