summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXin Li <delphij@google.com>2024-01-26 14:06:58 -0800
committerXin Li <delphij@google.com>2024-01-26 14:06:58 -0800
commit6a96d097cc1076180a8b7b1e218da5346c2e2442 (patch)
treebd95a632084706124ebbc0906a9cff2e406d1379
parentae696e6e3bac3459ea4d5b75eb700bd267ae49cf (diff)
parentfc5f33dc5ac601e5bbade4f78318e2ab03ca8715 (diff)
downloadMediaProvider-6a96d097cc1076180a8b7b1e218da5346c2e2442.tar.gz
Merge Android 24Q1 Release (ab/11220357)
Bug: 319669529 Merged-In: Id98d1ea9ea7d63cbeece73e59f36fdc3c7d561e0 Change-Id: Icb0cdfade56f7cff95bbd7b0ed5fa0fbdf84a72a
-rw-r--r--Android.bp20
-rw-r--r--AndroidManifest.xml13
-rw-r--r--TEST_MAPPING76
-rw-r--r--apex/Android.bp2
-rw-r--r--apex/framework/api/current.txt2
-rw-r--r--apex/framework/api/lint-baseline.txt5
-rw-r--r--apex/framework/api/module-lib-lint-baseline.txt9
-rw-r--r--apex/framework/api/system-lint-baseline.txt9
-rw-r--r--apex/framework/java/android/provider/CloudMediaProvider.java6
-rw-r--r--apex/framework/java/android/provider/CloudMediaProviderContract.java20
-rw-r--r--apex/framework/java/android/provider/MediaStore.java200
-rw-r--r--jni/FuseDaemon.cpp73
-rw-r--r--jni/FuseDaemon.h10
-rw-r--r--jni/com_android_providers_media_FuseDaemon.cpp24
-rw-r--r--jni/node.cpp8
-rw-r--r--jni/node_test.cpp16
-rw-r--r--legacy/src/com/android/providers/media/LegacyDatabaseHelper.java (renamed from src/com/android/providers/media/LegacyDatabaseHelper.java)3
-rw-r--r--mediaprovider_flags.aconfig8
-rw-r--r--mediaproviderutils.sh35
-rw-r--r--pdf/apk/Android.bp40
-rw-r--r--pdf/apk/AndroidManifest.xml22
-rw-r--r--pdf/apk/com.android.graphics.pdf.pk8bin0 -> 2373 bytes
-rw-r--r--pdf/apk/com.android.graphics.pdf.x509.pem35
-rw-r--r--pdf/apk/src/com/android/graphics/pdf/Placeholder.java26
-rw-r--r--pdf/framework/Android.bp (renamed from apex/pdf/framework/Android.bp)0
-rw-r--r--pdf/framework/api/current.txt (renamed from apex/pdf/framework/api/current.txt)0
-rw-r--r--pdf/framework/api/module-lib-current.txt (renamed from apex/pdf/framework/api/module-lib-current.txt)0
-rw-r--r--pdf/framework/api/module-lib-removed.txt (renamed from apex/pdf/framework/api/module-lib-removed.txt)0
-rw-r--r--pdf/framework/api/removed.txt (renamed from apex/pdf/framework/api/removed.txt)0
-rw-r--r--pdf/framework/api/system-current.txt (renamed from apex/pdf/framework/api/system-current.txt)0
-rw-r--r--pdf/framework/api/system-removed.txt (renamed from apex/pdf/framework/api/system-removed.txt)0
-rw-r--r--pdf/framework/java/android/graphics/pdf/Placeholder.java (renamed from apex/pdf/framework/java/android/graphics/pdf/Placeholder.java)0
-rw-r--r--res/drawable/error_icon.xml17
-rw-r--r--res/drawable/ic_artwork_camera.xml6
-rw-r--r--res/drawable/ic_background_circle.xml20
-rw-r--r--res/drawable/picker_app_icon.xml26
-rw-r--r--res/drawable/picker_item_check.xml50
-rw-r--r--res/drawable/picker_item_order.xml31
-rw-r--r--res/drawable/thumbnail_favorites.xml25
-rw-r--r--res/drawable/thumbnail_videos.xml26
-rw-r--r--res/layout/error_dialog.xml47
-rw-r--r--res/layout/fragment_picker_tab.xml68
-rw-r--r--res/layout/item_album_grid.xml13
-rw-r--r--res/layout/item_photo_grid.xml13
-rw-r--r--res/values-af/strings.xml18
-rw-r--r--res/values-am/strings.xml20
-rw-r--r--res/values-ar/strings.xml22
-rw-r--r--res/values-as/strings.xml15
-rw-r--r--res/values-az/strings.xml18
-rw-r--r--res/values-b+sr+Latn/strings.xml38
-rw-r--r--res/values-be/strings.xml18
-rw-r--r--res/values-bg/strings.xml26
-rw-r--r--res/values-bn/strings.xml18
-rw-r--r--res/values-bs/strings.xml29
-rw-r--r--res/values-ca/strings.xml20
-rw-r--r--res/values-cs/strings.xml20
-rw-r--r--res/values-da/strings.xml18
-rw-r--r--res/values-de/strings.xml24
-rw-r--r--res/values-el/strings.xml20
-rw-r--r--res/values-en-rAU/strings.xml15
-rw-r--r--res/values-en-rCA/strings.xml15
-rw-r--r--res/values-en-rGB/strings.xml15
-rw-r--r--res/values-en-rIN/strings.xml15
-rw-r--r--res/values-en-rXC/strings.xml15
-rw-r--r--res/values-es-rUS/strings.xml20
-rw-r--r--res/values-es/strings.xml24
-rw-r--r--res/values-et/strings.xml27
-rw-r--r--res/values-eu/strings.xml20
-rw-r--r--res/values-fa/strings.xml22
-rw-r--r--res/values-fi/strings.xml18
-rw-r--r--res/values-fr-rCA/strings.xml22
-rw-r--r--res/values-fr/strings.xml24
-rw-r--r--res/values-gl/strings.xml18
-rw-r--r--res/values-gu/strings.xml20
-rw-r--r--res/values-hi/strings.xml23
-rw-r--r--res/values-hr/strings.xml18
-rw-r--r--res/values-hu/strings.xml18
-rw-r--r--res/values-hy/strings.xml22
-rw-r--r--res/values-in/strings.xml18
-rw-r--r--res/values-is/strings.xml20
-rw-r--r--res/values-it/strings.xml23
-rw-r--r--res/values-iw/strings.xml18
-rw-r--r--res/values-ja/strings.xml19
-rw-r--r--res/values-ka/strings.xml22
-rw-r--r--res/values-kk/strings.xml20
-rw-r--r--res/values-km/strings.xml17
-rw-r--r--res/values-kn/strings.xml28
-rw-r--r--res/values-ko/strings.xml22
-rw-r--r--res/values-ky/strings.xml22
-rw-r--r--res/values-lo/strings.xml18
-rw-r--r--res/values-lt/strings.xml18
-rw-r--r--res/values-lv/strings.xml18
-rw-r--r--res/values-mk/strings.xml28
-rw-r--r--res/values-ml/strings.xml15
-rw-r--r--res/values-mn/strings.xml22
-rw-r--r--res/values-mr/strings.xml18
-rw-r--r--res/values-ms/strings.xml18
-rw-r--r--res/values-my/strings.xml22
-rw-r--r--res/values-nb/strings.xml18
-rw-r--r--res/values-ne/strings.xml18
-rw-r--r--res/values-night-v31/styles.xml5
-rw-r--r--res/values-night/styles.xml7
-rw-r--r--res/values-nl/strings.xml18
-rw-r--r--res/values-or/strings.xml32
-rw-r--r--res/values-pa/strings.xml20
-rw-r--r--res/values-pl/strings.xml22
-rw-r--r--res/values-pt-rBR/strings.xml21
-rw-r--r--res/values-pt-rPT/strings.xml22
-rw-r--r--res/values-pt/strings.xml21
-rw-r--r--res/values-ro/strings.xml18
-rw-r--r--res/values-ru/strings.xml22
-rw-r--r--res/values-si/strings.xml18
-rw-r--r--res/values-sk/strings.xml23
-rw-r--r--res/values-sl/strings.xml15
-rw-r--r--res/values-sq/strings.xml24
-rw-r--r--res/values-sr/strings.xml38
-rw-r--r--res/values-sv/strings.xml18
-rw-r--r--res/values-sw/strings.xml28
-rw-r--r--res/values-ta/strings.xml22
-rw-r--r--res/values-te/strings.xml21
-rw-r--r--res/values-th/strings.xml18
-rw-r--r--res/values-tl/strings.xml20
-rw-r--r--res/values-tr/strings.xml18
-rw-r--r--res/values-uk/strings.xml18
-rw-r--r--res/values-ur/strings.xml18
-rw-r--r--res/values-uz/strings.xml24
-rw-r--r--res/values-v31/styles.xml5
-rw-r--r--res/values-vi/strings.xml34
-rw-r--r--res/values-watch/dimens.xml19
-rw-r--r--res/values-zh-rCN/strings.xml30
-rw-r--r--res/values-zh-rHK/strings.xml25
-rw-r--r--res/values-zh-rTW/strings.xml15
-rw-r--r--res/values-zu/strings.xml20
-rw-r--r--res/values/attrs.xml6
-rw-r--r--res/values/dimens.xml10
-rw-r--r--res/values/strings.xml45
-rw-r--r--res/values/styles.xml27
-rw-r--r--src/com/android/providers/media/ConfigStore.java150
-rw-r--r--src/com/android/providers/media/DatabaseBackupAndRecovery.java381
-rw-r--r--src/com/android/providers/media/DatabaseHelper.java77
-rw-r--r--src/com/android/providers/media/LocalUriMatcher.java3
-rw-r--r--src/com/android/providers/media/MediaGrants.java404
-rw-r--r--src/com/android/providers/media/MediaProvider.java1389
-rw-r--r--src/com/android/providers/media/MediaProviderShellCommand.java8
-rw-r--r--src/com/android/providers/media/MediaService.java2
-rw-r--r--src/com/android/providers/media/PermissionActivity.java8
-rw-r--r--src/com/android/providers/media/PickerUriResolver.java11
-rw-r--r--src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java2
-rw-r--r--src/com/android/providers/media/fuse/FuseDaemon.java26
-rw-r--r--src/com/android/providers/media/photopicker/DataLoaderThread.java103
-rw-r--r--src/com/android/providers/media/photopicker/DialogUtils.java57
-rw-r--r--src/com/android/providers/media/photopicker/NotificationContentObserver.java174
-rw-r--r--src/com/android/providers/media/photopicker/PhotoPickerActivity.java203
-rw-r--r--src/com/android/providers/media/photopicker/PhotoPickerProvider.java3
-rw-r--r--src/com/android/providers/media/photopicker/PickerDataLayer.java355
-rw-r--r--src/com/android/providers/media/photopicker/PickerSyncController.java1042
-rw-r--r--src/com/android/providers/media/photopicker/SelectedMediaPreloader.java139
-rw-r--r--src/com/android/providers/media/photopicker/TEST_MAPPING22
-rw-r--r--src/com/android/providers/media/photopicker/data/CloudProviderQueryExtras.java85
-rw-r--r--src/com/android/providers/media/photopicker/data/ExternalDbFacade.java115
-rw-r--r--src/com/android/providers/media/photopicker/data/ItemsProvider.java290
-rw-r--r--src/com/android/providers/media/photopicker/data/PaginationParameters.java104
-rw-r--r--src/com/android/providers/media/photopicker/data/PickerDatabaseHelper.java32
-rw-r--r--src/com/android/providers/media/photopicker/data/PickerDbFacade.java465
-rw-r--r--src/com/android/providers/media/photopicker/data/PickerSyncRequestExtras.java96
-rw-r--r--src/com/android/providers/media/photopicker/data/Selection.java209
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/GlideLoadable.java70
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/PickerGlideModule.java4
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/PickerModelLoader.java24
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/PickerModelLoaderFactory.java5
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/PickerPreloadModelProvider.java101
-rw-r--r--src/com/android/providers/media/photopicker/data/glide/PickerThumbnailFetcher.java95
-rw-r--r--src/com/android/providers/media/photopicker/data/model/Category.java23
-rw-r--r--src/com/android/providers/media/photopicker/data/model/Item.java67
-rw-r--r--src/com/android/providers/media/photopicker/metrics/NonUiEventLogger.java270
-rw-r--r--src/com/android/providers/media/photopicker/metrics/PhotoPickerUiEventLogger.java425
-rw-r--r--src/com/android/providers/media/photopicker/sync/CloseableReentrantLock.java92
-rw-r--r--src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorker.java152
-rw-r--r--src/com/android/providers/media/photopicker/sync/ImmediateSyncWorker.java132
-rw-r--r--src/com/android/providers/media/photopicker/sync/MediaResetWorker.java209
-rw-r--r--src/com/android/providers/media/photopicker/sync/PickerSyncLockManager.java137
-rw-r--r--src/com/android/providers/media/photopicker/sync/PickerSyncManager.java406
-rw-r--r--src/com/android/providers/media/photopicker/sync/PickerSyncNotificationHelper.java98
-rw-r--r--src/com/android/providers/media/photopicker/sync/ProactiveSyncWorker.java143
-rw-r--r--src/com/android/providers/media/photopicker/sync/SyncTracker.java108
-rw-r--r--src/com/android/providers/media/photopicker/sync/SyncTrackerRegistry.java172
-rw-r--r--src/com/android/providers/media/photopicker/ui/AlbumGridHolder.java46
-rw-r--r--src/com/android/providers/media/photopicker/ui/AlbumsTabAdapter.java6
-rw-r--r--src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java35
-rw-r--r--src/com/android/providers/media/photopicker/ui/ImageLoader.java97
-rw-r--r--src/com/android/providers/media/photopicker/ui/ItemsAction.java52
-rw-r--r--src/com/android/providers/media/photopicker/ui/MediaItemGridViewHolder.java75
-rw-r--r--src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java67
-rw-r--r--src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java520
-rw-r--r--src/com/android/providers/media/photopicker/ui/PreviewAdapter.java35
-rw-r--r--src/com/android/providers/media/photopicker/ui/PreviewFragment.java70
-rw-r--r--src/com/android/providers/media/photopicker/ui/PreviewVideoHolder.java13
-rw-r--r--src/com/android/providers/media/photopicker/ui/TEST_MAPPING26
-rw-r--r--src/com/android/providers/media/photopicker/ui/TabAdapter.java78
-rw-r--r--src/com/android/providers/media/photopicker/ui/TabContainerFragment.java56
-rw-r--r--src/com/android/providers/media/photopicker/ui/TabFragment.java262
-rw-r--r--src/com/android/providers/media/photopicker/ui/ViewPager2Wrapper.java12
-rw-r--r--src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java10
-rw-r--r--src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java16
-rw-r--r--src/com/android/providers/media/photopicker/ui/settings/CloudMediaProviderAccount.java47
-rw-r--r--src/com/android/providers/media/photopicker/ui/settings/CloudProviderMediaCollectionInfo.java62
-rw-r--r--src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaSelectFragment.java74
-rw-r--r--src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModel.java64
-rw-r--r--src/com/android/providers/media/photopicker/ui/settings/SettingsProfileSelectFragment.java2
-rw-r--r--src/com/android/providers/media/photopicker/util/CategoryOrganiserUtils.java78
-rw-r--r--src/com/android/providers/media/photopicker/util/CloudProviderUtils.java53
-rw-r--r--src/com/android/providers/media/photopicker/util/ThreadUtils.java45
-rw-r--r--src/com/android/providers/media/photopicker/util/exceptions/UnableToAcquireLockException.java31
-rw-r--r--src/com/android/providers/media/photopicker/viewmodel/BannerController.java84
-rw-r--r--src/com/android/providers/media/photopicker/viewmodel/BannerManager.java120
-rw-r--r--src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java1086
-rw-r--r--src/com/android/providers/media/scan/ModernMediaScanner.java53
-rw-r--r--src/com/android/providers/media/scan/NullMediaScanner.java71
-rw-r--r--src/com/android/providers/media/stableuris/dao/BackupIdRow.java38
-rw-r--r--src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceService.java2
-rw-r--r--src/com/android/providers/media/util/FileUtils.java14
-rw-r--r--src/com/android/providers/media/util/SpecialFormatDetector.java5
-rw-r--r--src/com/android/providers/media/util/UserCache.java4
-rw-r--r--tests/Android.bp19
-rw-r--r--tests/AndroidManifest.xml34
-rw-r--r--tests/client/Android.bp1
-rw-r--r--tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java3
-rw-r--r--tests/client/src/com/android/providers/media/client/PerformanceTest.java2
-rw-r--r--tests/client/src/com/android/providers/media/client/PlaylistPerformanceTest.java2
-rw-r--r--tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java3
-rw-r--r--tests/client/src/com/android/providers/media/client/PublicVolumeTest.java3
-rw-r--r--tests/src/com/android/providers/media/ConfigStoreTest.java75
-rw-r--r--tests/src/com/android/providers/media/DatabaseBackupAndRecoveryTest.java80
-rw-r--r--tests/src/com/android/providers/media/IdleServiceTest.java233
-rw-r--r--tests/src/com/android/providers/media/IsolatedContext.java23
-rw-r--r--tests/src/com/android/providers/media/MediaGrantsTest.java327
-rw-r--r--tests/src/com/android/providers/media/MediaProviderForFuseTest.java19
-rw-r--r--tests/src/com/android/providers/media/MediaProviderTest.java93
-rw-r--r--tests/src/com/android/providers/media/PickerProviderMediaGenerator.java177
-rw-r--r--tests/src/com/android/providers/media/PickerUriResolverTest.java3
-rw-r--r--tests/src/com/android/providers/media/PublicVolumeTest.java2
-rw-r--r--tests/src/com/android/providers/media/TestConfigStore.java66
-rw-r--r--tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java11
-rw-r--r--tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java13
-rw-r--r--tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java15
-rw-r--r--tests/src/com/android/providers/media/cloudproviders/FlakyCloudProvider.java170
-rw-r--r--tests/src/com/android/providers/media/library/RunOnlyOnPostsubmit.java30
-rw-r--r--tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java707
-rw-r--r--tests/src/com/android/providers/media/photopicker/LocalProvider.java2
-rw-r--r--tests/src/com/android/providers/media/photopicker/NotificationContentObserverTest.java124
-rw-r--r--tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java239
-rw-r--r--tests/src/com/android/providers/media/photopicker/PickerSyncControllerTest.java1157
-rw-r--r--tests/src/com/android/providers/media/photopicker/SafetyProtectionUtilsTest.java2
-rw-r--r--tests/src/com/android/providers/media/photopicker/TEST_MAPPING42
-rw-r--r--tests/src/com/android/providers/media/photopicker/TestableContentObserverCallback.java41
-rw-r--r--tests/src/com/android/providers/media/photopicker/data/ExternalDbFacadeTest.java811
-rw-r--r--tests/src/com/android/providers/media/photopicker/data/PickerDatabaseHelperTest.java4
-rw-r--r--tests/src/com/android/providers/media/photopicker/data/PickerDbFacadeTest.java976
-rw-r--r--tests/src/com/android/providers/media/photopicker/data/SelectionTest.java111
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java12
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java49
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java11
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/BottomSheetIdlingResource.java3
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/DisabledAccessibilityTest.java296
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/MaxSelectionTest.java37
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java42
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java46
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/NoItemsTest.java4
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/OrientationUtils.java10
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerAccessibilityDisabledTestActivity.java34
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java194
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java33
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java37
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerUserSelectActivityTest.java181
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java26
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PreviewFragmentAssertionUtils.java41
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectLongPressTest.java2
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PreviewMultiSelectTest.java14
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/PreviewSingleSelectTest.java90
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/ProgressBarTest.java103
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatMultiSelectTest.java5
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/SpecialFormatSingleSelectTest.java21
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/UiEventLoggerTestUtils.java60
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/ViewPager2IdlingResource.java4
-rw-r--r--tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java2
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/ImmediateAlbumSyncWorkerTest.java286
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/ImmediateSyncWorkerTest.java248
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/MediaResetWorkerTest.java469
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/PickerSyncLockManagerTest.java144
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java439
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/ProactiveSyncWorkerTest.java244
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java76
-rw-r--r--tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java128
-rw-r--r--tests/src/com/android/providers/media/photopicker/ui/PhotosTabAdapterTest.java10
-rw-r--r--tests/src/com/android/providers/media/photopicker/ui/settings/SettingsCloudMediaViewModelTest.java42
-rw-r--r--tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java61
-rw-r--r--tests/src/com/android/providers/media/photopicker/util/ThreadUtilsTest.java49
-rw-r--r--tests/src/com/android/providers/media/photopicker/viewmodel/BannerControllerTest.java95
-rw-r--r--tests/src/com/android/providers/media/photopicker/viewmodel/BannerTestUtils.java73
-rw-r--r--tests/src/com/android/providers/media/photopicker/viewmodel/CategoryOrganiserTest.java114
-rw-r--r--tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java524
-rw-r--r--tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java442
-rw-r--r--tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java5
-rw-r--r--tests/src/com/android/providers/media/scan/NullMediaScannerTest.java44
-rw-r--r--tests/src/com/android/providers/media/stableuris/dao/BackupIdRowTest.java4
-rw-r--r--tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java289
-rw-r--r--tests/src/com/android/providers/media/util/FileCreationUtils.java21
-rw-r--r--tests/src/com/android/providers/media/util/FileUtilsTest.java4
-rw-r--r--tests/src/com/android/providers/media/util/PermissionUtilsTest.java7
-rw-r--r--tools/photopicker/res/layout/activity_main.xml7
-rw-r--r--tools/photopicker/src/com/android/providers/media/tools/photopicker/PhotoPickerToolActivity.java6
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
new file mode 100644
index 000000000..6004032cb
--- /dev/null
+++ b/pdf/apk/com.android.graphics.pdf.pk8
Binary files differ
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&#8230;</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);
+ }
}
}