summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.clang-format13
-rw-r--r--Android.bp104
-rw-r--r--AndroidManifest-app.xml33
-rw-r--r--AndroidManifest-lib.xml3
-rw-r--r--NOTICE202
-rw-r--r--PREUPLOAD.cfg12
-rw-r--r--README.md8
-rw-r--r--TEST_MAPPING10
-rw-r--r--aconfig/Android.bp31
-rw-r--r--aconfig/FeatureFlags.aconfig118
-rw-r--r--aconfig/README.md20
-rw-r--r--java/aidl/com/android/intentresolver/IChooserController.aidl8
-rw-r--r--java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl9
-rw-r--r--java/res/color/resolver_profile_tab_text.xml4
-rw-r--r--java/res/drawable/bottomsheet_background.xml2
-rw-r--r--java/res/drawable/checkbox.xml10
-rw-r--r--java/res/drawable/chevron_right.xml2
-rw-r--r--java/res/drawable/chooser_action_button_bg.xml2
-rw-r--r--java/res/drawable/chooser_content_preview_rounded.xml2
-rw-r--r--java/res/drawable/chooser_direct_share_label_placeholder.xml37
-rw-r--r--java/res/drawable/chooser_row_layer_list.xml2
-rw-r--r--java/res/drawable/edit_action_background.xml2
-rw-r--r--java/res/drawable/ic_drag_handle.xml2
-rw-r--r--java/res/drawable/ic_play_circle_filled_24px.xml3
-rw-r--r--java/res/drawable/inset_resolver_profile_tab_bg.xml21
-rw-r--r--java/res/drawable/resolver_outlined_button_bg.xml2
-rw-r--r--java/res/drawable/resolver_profile_tab_bg.xml6
-rw-r--r--java/res/layout-h480dp/image_preview_image_item.xml4
-rw-r--r--java/res/layout-h480dp/image_preview_loading_item.xml2
-rw-r--r--java/res/layout/chooser_action_row.xml2
-rw-r--r--java/res/layout/chooser_action_view.xml9
-rw-r--r--java/res/layout/chooser_grid_item.xml70
-rw-r--r--java/res/layout/chooser_grid_item_hover.xml72
-rw-r--r--java/res/layout/chooser_grid_preview_file.xml8
-rw-r--r--java/res/layout/chooser_grid_preview_files_text.xml5
-rw-r--r--java/res/layout/chooser_grid_preview_image.xml13
-rw-r--r--java/res/layout/chooser_grid_preview_text.xml11
-rw-r--r--java/res/layout/chooser_grid_scrollable_preview.xml (renamed from java/res/layout/chooser_grid.xml)77
-rw-r--r--java/res/layout/chooser_headline_row.xml29
-rw-r--r--java/res/layout/chooser_list_per_profile_wrap.xml (renamed from java/res/layout/chooser_list_per_profile.xml)8
-rw-r--r--java/res/layout/chooser_row.xml3
-rw-r--r--java/res/layout/chooser_row_direct_share.xml1
-rw-r--r--java/res/layout/image_preview_loading_item.xml2
-rw-r--r--java/res/layout/resolve_grid_item.xml8
-rw-r--r--java/res/layout/resolver_empty_states.xml16
-rw-r--r--java/res/layout/resolver_profile_tab_button.xml6
-rw-r--r--java/res/values-af/strings.xml22
-rw-r--r--java/res/values-am/strings.xml12
-rw-r--r--java/res/values-ar/strings.xml26
-rw-r--r--java/res/values-as/strings.xml12
-rw-r--r--java/res/values-az/strings.xml12
-rw-r--r--java/res/values-b+sr+Latn/strings.xml16
-rw-r--r--java/res/values-be/strings.xml12
-rw-r--r--java/res/values-bg/strings.xml14
-rw-r--r--java/res/values-bn/strings.xml16
-rw-r--r--java/res/values-bs/strings.xml14
-rw-r--r--java/res/values-ca/strings.xml14
-rw-r--r--java/res/values-cs/strings.xml12
-rw-r--r--java/res/values-da/strings.xml12
-rw-r--r--java/res/values-de/strings.xml16
-rw-r--r--java/res/values-el/strings.xml12
-rw-r--r--java/res/values-en-rAU/strings.xml12
-rw-r--r--java/res/values-en-rCA/strings.xml12
-rw-r--r--java/res/values-en-rGB/strings.xml12
-rw-r--r--java/res/values-en-rIN/strings.xml12
-rw-r--r--java/res/values-en-rXC/strings.xml11
-rw-r--r--java/res/values-es-rUS/strings.xml14
-rw-r--r--java/res/values-es/strings.xml14
-rw-r--r--java/res/values-et/strings.xml12
-rw-r--r--java/res/values-eu/strings.xml14
-rw-r--r--java/res/values-fa/strings.xml26
-rw-r--r--java/res/values-fi/strings.xml12
-rw-r--r--java/res/values-fr-rCA/strings.xml42
-rw-r--r--java/res/values-fr/strings.xml16
-rw-r--r--java/res/values-gl/strings.xml12
-rw-r--r--java/res/values-gu/strings.xml14
-rw-r--r--java/res/values-h480dp/dimens.xml2
-rw-r--r--java/res/values-h480dp/integers.xml2
-rw-r--r--java/res/values-hi/strings.xml14
-rw-r--r--java/res/values-hr/strings.xml16
-rw-r--r--java/res/values-hu/strings.xml12
-rw-r--r--java/res/values-hy/strings.xml12
-rw-r--r--java/res/values-in/strings.xml16
-rw-r--r--java/res/values-is/strings.xml12
-rw-r--r--java/res/values-it/strings.xml18
-rw-r--r--java/res/values-iw/strings.xml14
-rw-r--r--java/res/values-ja/strings.xml16
-rw-r--r--java/res/values-ka/strings.xml12
-rw-r--r--java/res/values-kk/strings.xml12
-rw-r--r--java/res/values-km/strings.xml12
-rw-r--r--java/res/values-kn/strings.xml18
-rw-r--r--java/res/values-ko/strings.xml12
-rw-r--r--java/res/values-ky/strings.xml12
-rw-r--r--java/res/values-lo/strings.xml14
-rw-r--r--java/res/values-lt/strings.xml12
-rw-r--r--java/res/values-lv/strings.xml16
-rw-r--r--java/res/values-mk/strings.xml14
-rw-r--r--java/res/values-ml/strings.xml14
-rw-r--r--java/res/values-mn/strings.xml12
-rw-r--r--java/res/values-mr/strings.xml14
-rw-r--r--java/res/values-ms/strings.xml12
-rw-r--r--java/res/values-my/strings.xml12
-rw-r--r--java/res/values-nb/strings.xml14
-rw-r--r--java/res/values-ne/strings.xml14
-rw-r--r--java/res/values-night/styles.xml22
-rw-r--r--java/res/values-nl/strings.xml14
-rw-r--r--java/res/values-or/strings.xml14
-rw-r--r--java/res/values-pa/strings.xml14
-rw-r--r--java/res/values-pl/strings.xml14
-rw-r--r--java/res/values-pt-rBR/strings.xml16
-rw-r--r--java/res/values-pt-rPT/strings.xml14
-rw-r--r--java/res/values-pt/strings.xml16
-rw-r--r--java/res/values-ro/strings.xml14
-rw-r--r--java/res/values-ru/strings.xml18
-rw-r--r--java/res/values-si/strings.xml12
-rw-r--r--java/res/values-sk/strings.xml16
-rw-r--r--java/res/values-sl/strings.xml12
-rw-r--r--java/res/values-sq/strings.xml14
-rw-r--r--java/res/values-sr/strings.xml16
-rw-r--r--java/res/values-sv/strings.xml14
-rw-r--r--java/res/values-sw/strings.xml22
-rw-r--r--java/res/values-sw600dp/dimens.xml1
-rw-r--r--java/res/values-ta/strings.xml12
-rw-r--r--java/res/values-te/strings.xml16
-rw-r--r--java/res/values-th/strings.xml12
-rw-r--r--java/res/values-tl/strings.xml16
-rw-r--r--java/res/values-tr/strings.xml14
-rw-r--r--java/res/values-uk/strings.xml14
-rw-r--r--java/res/values-ur/strings.xml12
-rw-r--r--java/res/values-uz/strings.xml12
-rw-r--r--java/res/values-vi/strings.xml14
-rw-r--r--java/res/values-zh-rCN/strings.xml12
-rw-r--r--java/res/values-zh-rHK/strings.xml12
-rw-r--r--java/res/values-zh-rTW/strings.xml12
-rw-r--r--java/res/values-zu/strings.xml12
-rw-r--r--java/res/values/attrs.xml13
-rw-r--r--java/res/values/dimens.xml14
-rw-r--r--java/res/values/integers.xml2
-rw-r--r--java/res/values/strings.xml49
-rw-r--r--java/res/values/styles.xml2
-rw-r--r--java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt81
-rw-r--r--java/src/android/service/chooser/ChooserSession.kt39
-rw-r--r--java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java582
-rw-r--r--java/src/com/android/intentresolver/AnnotatedUserHandles.java217
-rw-r--r--java/src/com/android/intentresolver/ChooserActionFactory.java170
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java2333
-rw-r--r--java/src/com/android/intentresolver/ChooserGridLayoutManager.java133
-rw-r--r--java/src/com/android/intentresolver/ChooserHelper.kt231
-rw-r--r--java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java83
-rw-r--r--java/src/com/android/intentresolver/ChooserListAdapter.java402
-rw-r--r--java/src/com/android/intentresolver/ChooserListController.java65
-rw-r--r--java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java6
-rw-r--r--java/src/com/android/intentresolver/ChooserRefinementManager.java131
-rw-r--r--java/src/com/android/intentresolver/ChooserRequestParameters.java483
-rw-r--r--java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java4
-rw-r--r--java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java15
-rw-r--r--java/src/com/android/intentresolver/ContentTypeHint.kt (renamed from java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt)13
-rw-r--r--java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt35
-rw-r--r--java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java235
-rw-r--r--java/src/com/android/intentresolver/IntentForwarderActivity.java12
-rw-r--r--java/src/com/android/intentresolver/IntentForwarding.kt111
-rw-r--r--java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt4
-rw-r--r--java/src/com/android/intentresolver/JavaFlowHelper.kt30
-rw-r--r--java/src/com/android/intentresolver/MainApplication.kt (renamed from java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt)9
-rw-r--r--java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java153
-rw-r--r--java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java136
-rw-r--r--java/src/com/android/intentresolver/PackagesChangedListener.kt22
-rw-r--r--java/src/com/android/intentresolver/ProfileAvailability.kt103
-rw-r--r--java/src/com/android/intentresolver/ProfileHelper.kt90
-rw-r--r--java/src/com/android/intentresolver/ResolvedComponentInfo.java4
-rw-r--r--java/src/com/android/intentresolver/ResolverActivity.java1697
-rw-r--r--java/src/com/android/intentresolver/ResolverHelper.kt129
-rw-r--r--java/src/com/android/intentresolver/ResolverInfoHelpers.kt34
-rw-r--r--java/src/com/android/intentresolver/ResolverListAdapter.java317
-rw-r--r--java/src/com/android/intentresolver/ResolverListController.java7
-rw-r--r--java/src/com/android/intentresolver/ResolverViewPager.java10
-rw-r--r--java/src/com/android/intentresolver/ShortcutSelectionLogic.java33
-rw-r--r--java/src/com/android/intentresolver/SimpleIconFactory.java35
-rw-r--r--java/src/com/android/intentresolver/StartsSelectedItem.kt21
-rw-r--r--java/src/com/android/intentresolver/TargetPresentationGetter.java67
-rw-r--r--java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java112
-rw-r--r--java/src/com/android/intentresolver/annotation/JavaInterop.kt28
-rw-r--r--java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java2
-rw-r--r--java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java51
-rw-r--r--java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java44
-rw-r--r--java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java19
-rw-r--r--java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java17
-rw-r--r--java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java3
-rw-r--r--java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java4
-rw-r--r--java/src/com/android/intentresolver/chooser/TargetInfo.java43
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java135
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java77
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileInfo.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java72
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt8
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt70
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoader.kt26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt45
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt156
-rw-r--r--java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt11
-rw-r--r--java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt209
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt210
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt76
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt106
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java90
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt60
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java57
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt111
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt101
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt29
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt28
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt32
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt32
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt29
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt (renamed from java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt)13
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt81
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt64
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt55
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt24
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt92
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt41
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt420
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt66
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt89
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt42
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt45
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt42
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt103
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt63
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt48
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt37
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt31
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt23
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt23
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt108
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt36
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt37
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt178
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt24
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt49
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt37
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt45
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt61
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt116
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt445
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt120
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt29
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt36
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt167
-rw-r--r--java/src/com/android/intentresolver/data/BroadcastSubscriber.kt73
-rw-r--r--java/src/com/android/intentresolver/data/model/ChooserRequest.kt203
-rw-r--r--java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt37
-rw-r--r--java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt36
-rw-r--r--java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt165
-rw-r--r--java/src/com/android/intentresolver/data/repository/UserInfoExt.kt45
-rw-r--r--java/src/com/android/intentresolver/data/repository/UserRepository.kt329
-rw-r--r--java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt53
-rw-r--r--java/src/com/android/intentresolver/data/repository/UserScopedService.kt67
-rw-r--r--java/src/com/android/intentresolver/domain/ChooserRequestExt.kt70
-rw-r--r--java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt92
-rw-r--r--java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt (renamed from java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt)25
-rw-r--r--java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java59
-rw-r--r--java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt20
-rw-r--r--java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java69
-rw-r--r--java/src/com/android/intentresolver/emptystate/EmptyState.java78
-rw-r--r--java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java37
-rw-r--r--java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java136
-rw-r--r--java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java55
-rw-r--r--java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java73
-rw-r--r--java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java118
-rw-r--r--java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java57
-rw-r--r--java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java94
-rw-r--r--java/src/com/android/intentresolver/ext/CreationExtrasExt.kt40
-rw-r--r--java/src/com/android/intentresolver/ext/IntentExt.kt45
-rw-r--r--java/src/com/android/intentresolver/ext/ParcelExt.kt27
-rw-r--r--java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt33
-rw-r--r--java/src/com/android/intentresolver/flags/Flags.kt30
-rw-r--r--java/src/com/android/intentresolver/grid/ChooserGridAdapter.java132
-rw-r--r--java/src/com/android/intentresolver/icon/ComposeIcon.kt88
-rw-r--r--java/src/com/android/intentresolver/icons/BaseLoadIconTask.java17
-rw-r--r--java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt118
-rw-r--r--java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt80
-rw-r--r--java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt41
-rw-r--r--java/src/com/android/intentresolver/icons/LabelInfo.kt19
-rw-r--r--java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java33
-rw-r--r--java/src/com/android/intentresolver/icons/LoadIconTask.java19
-rw-r--r--java/src/com/android/intentresolver/icons/LoadLabelTask.java39
-rw-r--r--java/src/com/android/intentresolver/icons/TargetDataLoader.kt20
-rw-r--r--java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt60
-rw-r--r--java/src/com/android/intentresolver/inject/ActivityModelModule.kt142
-rw-r--r--java/src/com/android/intentresolver/inject/ActivityModule.kt46
-rw-r--r--java/src/com/android/intentresolver/inject/ConcurrencyModule.kt72
-rw-r--r--java/src/com/android/intentresolver/inject/Qualifiers.kt46
-rw-r--r--java/src/com/android/intentresolver/inject/SingletonModule.kt38
-rw-r--r--java/src/com/android/intentresolver/inject/SystemServices.kt136
-rw-r--r--java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt42
-rw-r--r--java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt54
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt139
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt43
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt36
-rw-r--r--java/src/com/android/intentresolver/logging/EventLog.kt89
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogImpl.java (renamed from java/src/com/android/intentresolver/logging/EventLog.java)181
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogModule.kt46
-rw-r--r--java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt75
-rw-r--r--java/src/com/android/intentresolver/model/AbstractResolverComparator.java50
-rw-r--r--java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java81
-rw-r--r--java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java44
-rw-r--r--java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java59
-rw-r--r--java/src/com/android/intentresolver/platform/AppPredictionModule.kt42
-rw-r--r--java/src/com/android/intentresolver/platform/ImageEditorModule.kt51
-rw-r--r--java/src/com/android/intentresolver/platform/NearbyShareModule.kt48
-rw-r--r--java/src/com/android/intentresolver/platform/SettingsImpl.kt59
-rw-r--r--java/src/com/android/intentresolver/platform/SettingsModule.kt33
-rw-r--r--java/src/com/android/intentresolver/platform/SettingsProxy.kt92
-rw-r--r--java/src/com/android/intentresolver/profiles/AdapterBinder.java31
-rw-r--r--java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java (renamed from java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java)107
-rw-r--r--java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java705
-rw-r--r--java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java46
-rw-r--r--java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java27
-rw-r--r--java/src/com/android/intentresolver/profiles/ProfileDescriptor.java82
-rw-r--r--java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java (renamed from java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java)64
-rw-r--r--java/src/com/android/intentresolver/profiles/TabConfig.java38
-rw-r--r--java/src/com/android/intentresolver/shared/model/ActivityModel.kt85
-rw-r--r--java/src/com/android/intentresolver/shared/model/Profile.kt52
-rw-r--r--java/src/com/android/intentresolver/shared/model/User.kt52
-rw-r--r--java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt42
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt58
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt137
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java5
-rw-r--r--java/src/com/android/intentresolver/ui/ActionTitle.java88
-rw-r--r--java/src/com/android/intentresolver/ui/ProfilePagerResources.kt61
-rw-r--r--java/src/com/android/intentresolver/ui/ShareResultSender.kt181
-rw-r--r--java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt94
-rw-r--r--java/src/com/android/intentresolver/ui/model/ResolverRequest.kt68
-rw-r--r--java/src/com/android/intentresolver/ui/model/ShareAction.kt23
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt202
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt131
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt58
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt59
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt67
-rw-r--r--java/src/com/android/intentresolver/util/CancellationSignalUtils.kt41
-rw-r--r--java/src/com/android/intentresolver/util/Flow.kt10
-rw-r--r--java/src/com/android/intentresolver/util/ParallelIteration.kt71
-rw-r--r--java/src/com/android/intentresolver/util/SyncUtils.kt33
-rw-r--r--java/src/com/android/intentresolver/util/cursor/CursorView.kt59
-rw-r--r--java/src/com/android/intentresolver/util/cursor/Cursors.kt87
-rw-r--r--java/src/com/android/intentresolver/util/cursor/PagedCursor.kt52
-rw-r--r--java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt46
-rw-r--r--java/src/com/android/intentresolver/validation/Findings.kt120
-rw-r--r--java/src/com/android/intentresolver/validation/Validation.kt137
-rw-r--r--java/src/com/android/intentresolver/validation/ValidationResult.kt26
-rw-r--r--java/src/com/android/intentresolver/validation/types/IntentOrUri.kt62
-rw-r--r--java/src/com/android/intentresolver/validation/types/ParceledArray.kt84
-rw-r--r--java/src/com/android/intentresolver/validation/types/SimpleValue.kt60
-rw-r--r--java/src/com/android/intentresolver/validation/types/Validators.kt26
-rw-r--r--java/src/com/android/intentresolver/widget/ActionRow.kt4
-rw-r--r--java/src/com/android/intentresolver/widget/BadgeTextView.kt104
-rw-r--r--java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt124
-rw-r--r--java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt154
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt13
-rw-r--r--java/src/com/android/intentresolver/widget/NestedScrollView.java2611
-rw-r--r--java/src/com/android/intentresolver/widget/NestedScrollView.java.patch103
-rw-r--r--java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt8
-rw-r--r--java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java117
-rw-r--r--java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt51
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt215
-rw-r--r--java/src/com/android/intentresolver/widget/ViewExtensions.kt34
-rw-r--r--java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt29
-rw-r--r--java/tests/Android.bp44
-rw-r--r--java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt79
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt71
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt88
-rw-r--r--java/tests/src/com/android/intentresolver/FeatureFlagRule.kt56
-rw-r--r--java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt149
-rw-r--r--java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt313
-rw-r--r--java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt204
-rw-r--r--java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt56
-rw-r--r--java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt399
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt225
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt366
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt349
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt166
-rw-r--r--java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt482
-rw-r--r--lint-baseline.xml2425
-rw-r--r--proguard.flags5
-rw-r--r--tests/README.md33
-rw-r--r--tests/activity/Android.bp69
-rw-r--r--tests/activity/AndroidManifest.xml (renamed from java/tests/AndroidManifest.xml)14
-rw-r--r--tests/activity/AndroidTest.xml33
-rw-r--r--tests/activity/res/drawable/test320x240.png (renamed from java/tests/res/drawable/test320x240.png)bin39533 -> 39533 bytes
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java (renamed from java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java)64
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserActivityTest.java (renamed from java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java)919
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java (renamed from java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java)67
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java (renamed from java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java)135
-rw-r--r--tests/activity/src/com/android/intentresolver/IChooserWrapper.java (renamed from java/tests/src/com/android/intentresolver/IChooserWrapper.java)14
-rw-r--r--tests/activity/src/com/android/intentresolver/ResolverActivityTest.java (renamed from java/tests/src/com/android/intentresolver/ResolverActivityTest.java)215
-rw-r--r--tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java (renamed from java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java)119
-rw-r--r--tests/activity/src/com/android/intentresolver/TestContentProvider.kt (renamed from java/tests/src/com/android/intentresolver/TestContentProvider.kt)0
-rw-r--r--tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt28
-rw-r--r--tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt39
-rw-r--r--tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt33
-rw-r--r--tests/integration/Android.bp44
-rw-r--r--tests/integration/AndroidManifest.xml24
-rw-r--r--tests/integration/AndroidTest.xml38
-rw-r--r--tests/integration/res/values/strings.xml18
-rw-r--r--tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt (renamed from java/src/com/android/intentresolver/SecureSettings.kt)14
-rw-r--r--tests/shared/Android.bp39
-rw-r--r--tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt (renamed from java/tests/src/com/android/intentresolver/TestApplication.kt)13
-rw-r--r--tests/shared/src/com/android/intentresolver/FakeImageLoader.kt (renamed from java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt)22
-rw-r--r--tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt25
-rw-r--r--tests/shared/src/com/android/intentresolver/MatcherUtils.java (renamed from java/tests/src/com/android/intentresolver/MatcherUtils.java)2
-rw-r--r--tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt293
-rw-r--r--tests/shared/src/com/android/intentresolver/ResolverDataProvider.java (renamed from java/tests/src/com/android/intentresolver/ResolverDataProvider.java)24
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt41
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt21
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt28
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt (renamed from java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt)22
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt33
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt22
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt22
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt124
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt33
-rw-r--r--tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt65
-rw-r--r--tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt (renamed from java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt)23
-rw-r--r--tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt45
-rw-r--r--tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt26
-rw-r--r--tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt23
-rw-r--r--tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt206
-rw-r--r--tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt95
-rw-r--r--tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt43
-rw-r--r--tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt243
-rw-r--r--tests/unit/Android.bp66
-rw-r--r--tests/unit/AndroidManifest.xml23
-rw-r--r--tests/unit/AndroidTest.xml (renamed from java/tests/AndroidTest.xml)8
-rw-r--r--tests/unit/res/values/strings.xml18
-rw-r--r--tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt (renamed from java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt)164
-rw-r--r--tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt197
-rw-r--r--tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt (renamed from java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt)112
-rw-r--r--tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt (renamed from java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt)61
-rw-r--r--tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt (renamed from java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt)31
-rw-r--r--tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt50
-rw-r--r--tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt74
-rw-r--r--tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt204
-rw-r--r--tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt1066
-rw-r--r--tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt388
-rw-r--r--tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt236
-rw-r--r--tests/unit/src/com/android/intentresolver/TestHelpers.kt (renamed from java/tests/src/com/android/intentresolver/TestHelpers.kt)23
-rw-r--r--tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt (renamed from java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt)348
-rw-r--r--tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt437
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt (renamed from java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt)116
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt (renamed from java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt)0
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt71
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt87
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt412
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt (renamed from java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt)55
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt560
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt496
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt142
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt227
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt100
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt144
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt80
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt395
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt120
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt322
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt120
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt201
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt124
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt112
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt72
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt471
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt373
-rw-r--r--tests/unit/src/com/android/intentresolver/coroutines/Flow.kt89
-rw-r--r--tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt108
-rw-r--r--tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt224
-rw-r--r--tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt206
-rw-r--r--tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt65
-rw-r--r--tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt82
-rw-r--r--tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt227
-rw-r--r--tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt222
-rw-r--r--tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt69
-rw-r--r--tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt85
-rw-r--r--tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt187
-rw-r--r--tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt420
-rw-r--r--tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java (renamed from java/tests/src/com/android/intentresolver/logging/EventLogTest.java)94
-rw-r--r--tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java (renamed from java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java)16
-rw-r--r--tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt81
-rw-r--r--tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt144
-rw-r--r--tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt95
-rw-r--r--tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt342
-rw-r--r--tests/unit/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt71
-rw-r--r--tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt698
-rw-r--r--tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt (renamed from java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt)84
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt232
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt117
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt317
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt174
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt126
-rw-r--r--tests/unit/src/com/android/intentresolver/util/TestExecutor.kt (renamed from java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt)31
-rw-r--r--tests/unit/src/com/android/intentresolver/util/TestKosmos.kt51
-rw-r--r--tests/unit/src/com/android/intentresolver/util/TruthUtils.kt26
-rw-r--r--tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt (renamed from java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt)16
-rw-r--r--tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt132
-rw-r--r--tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt131
-rw-r--r--tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt117
-rw-r--r--tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt92
-rw-r--r--tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt (renamed from java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt)28
511 files changed, 39242 insertions, 10489 deletions
diff --git a/.clang-format b/.clang-format
deleted file mode 100644
index 03af56d6..00000000
--- a/.clang-format
+++ /dev/null
@@ -1,13 +0,0 @@
-BasedOnStyle: Google
-
-AccessModifierOffset: -4
-AlignOperands: false
-AllowShortFunctionsOnASingleLine: Inline
-AlwaysBreakBeforeMultilineStrings: false
-ColumnLimit: 100
-CommentPragmas: NOLINT:.*
-ConstructorInitializerIndentWidth: 6
-ContinuationIndentWidth: 8
-IndentWidth: 4
-PenaltyBreakBeforeFirstCallParameter: 100000
-SpacesBeforeTrailingComments: 1
diff --git a/Android.bp b/Android.bp
index 9d0a8ee6..9dccb9f1 100644
--- a/Android.bp
+++ b/Android.bp
@@ -1,5 +1,5 @@
//
-// Copyright (C) 2021 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.
@@ -15,56 +15,33 @@
//
package {
- // See: http://go/android-license-faq
- // This was chosen for Sharesheet to match existing packages.
- default_applicable_licenses: ["packages_modules_IntentResolver_license"],
+ default_applicable_licenses: ["Android-Apache-2.0"],
+ default_visibility: [":__subpackages__"],
}
-license {
- name: "packages_modules_IntentResolver_license",
- visibility: [":__subpackages__"],
- license_kinds: [
- "SPDX-license-identifier-Apache-2.0",
- ],
- license_text: [
- "NOTICE",
- ],
-}
-
-filegroup {
- name: "ReleaseSources",
- srcs: [
- "java/src-release/**/*.kt",
- ],
-}
-
-filegroup {
- name: "DebugSources",
- srcs: [
- "java/src-debug/**/*.kt",
- ],
-}
-
-android_library {
- name: "IntentResolver-core",
- min_sdk_version: "current",
+java_defaults {
+ name: "Java_Defaults",
srcs: [
"java/src/**/*.java",
"java/src/**/*.kt",
- ":ReleaseSources",
+ "java/aidl/**/I*.aidl",
],
- product_variables: {
- debuggable: {
- srcs: [":DebugSources"],
- exclude_srcs: [":ReleaseSources"],
- }
- },
resource_dirs: [
"java/res",
],
-
manifest: "AndroidManifest-lib.xml",
+ min_sdk_version: "current",
+ lint: {
+ strict_updatability_linting: false,
+ extra_check_modules: ["SystemUILintChecker"],
+ warning_checks: ["MissingApacheLicenseDetector"],
+ baseline_filename: "lint-baseline.xml",
+ },
+}
+android_library {
+ name: "IntentResolver-core",
+ defaults: ["Java_Defaults"],
static_libs: [
"androidx.annotation_annotation",
"androidx.concurrent_concurrent-futures",
@@ -75,40 +52,63 @@ android_library {
"androidx.lifecycle_lifecycle-extensions",
"androidx.lifecycle_lifecycle-runtime-ktx",
"androidx.lifecycle_lifecycle-viewmodel-ktx",
+ "dagger2",
+ "//frameworks/libs/systemui:com_android_systemui_shared_flags_lib",
+ "hilt_android",
+ "IntentResolverFlagsLib",
+ "iconloader",
+ "jsr330",
"kotlin-stdlib",
"kotlinx_coroutines",
"kotlinx-coroutines-android",
"//external/kotlinc:kotlin-annotations",
"guava",
- "SystemUIFlagsLib",
+ "PlatformComposeCore",
+ "PlatformComposeSceneTransitionLayout",
+ "androidx.compose.runtime_runtime",
+ "androidx.compose.material3_material3",
+ "androidx.compose.material_material-icons-extended",
+ "androidx.activity_activity-compose",
+ "androidx.compose.animation_animation-graphics",
+ "androidx.lifecycle_lifecycle-viewmodel-compose",
+ "androidx.lifecycle_lifecycle-runtime-compose",
],
-
- lint: {
- strict_updatability_linting: false,
- },
-
- optimize: {
- proguard_flags_files: ["proguard.flags"],
+ javacflags: [
+ "-Adagger.fastInit=enabled",
+ "-Adagger.explicitBindingConflictsWithInject=ERROR",
+ "-Adagger.strictMultibindingValidation=enabled",
+ ],
+ aidl: {
+ local_include_dirs: ["java/aidl"],
},
}
-android_app {
- name: "IntentResolver",
+java_defaults {
+ name: "App_Defaults",
min_sdk_version: "current",
+ platform_apis: true,
certificate: "platform",
privileged: true,
manifest: "AndroidManifest-app.xml",
required: [
"privapp_whitelist_com.android.intentresolver",
],
- srcs: ["src/**/*.java"],
- platform_apis: true,
+}
+
+android_app {
+ name: "IntentResolver",
+ defaults: ["App_Defaults"],
static_libs: [
"IntentResolver-core",
],
optimize: {
enabled: true,
+ optimize: true,
+ shrink: true,
+ optimized_shrink_resources: true,
+ proguard_flags_files: ["proguard.flags"],
},
+ visibility: ["//visibility:public"],
apex_available: [
"//apex_available:platform",
"com.android.intentresolver",
diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml
index 57ea497b..f5d2ff8e 100644
--- a/AndroidManifest-app.xml
+++ b/AndroidManifest-app.xml
@@ -17,12 +17,16 @@
*/
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
package="com.android.intentresolver"
android:versionCode="0"
android:versionName="2021-11"
coreApp="true">
+ <uses-permission android:name="android.permission.INTERNAL_SYSTEM_WINDOW" />
+
<application
+ android:name=".MainApplication"
android:hardwareAccelerated="true"
android:label="@string/app_label"
android:directBootAware="true"
@@ -30,6 +34,17 @@
android:requiredForAllUsers="true"
android:supportsRtl="true">
+ <activity android:name=".ChooserActivity"
+ android:enabled="true"
+ android:theme="@style/Theme.DeviceDefault.Chooser"
+ android:finishOnCloseSystemDialogs="true"
+ android:excludeFromRecents="true"
+ android:documentLaunchMode="never"
+ android:relinquishTaskIdentity="true"
+ android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
+ android:visibleToInstantApps="true"
+ android:exported="false" />
+
<!-- This alias needs to be maintained until there are no more devices that could be
upgrading from T QPR3. (b/283722356) -->
<activity-alias
@@ -37,26 +52,18 @@
android:targetActivity=".ChooserActivity"
android:exported="true">
- <!-- This intent filter is assigned a priority greater than 100 so
- that it will take precedence over the framework ChooserActivity
- in the process of resolving implicit action.CHOOSER intents
- whenever this activity is enabled by the experiment flag. -->
<intent-filter android:priority="500">
<action android:name="android.intent.action.CHOOSER" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.VOICE" />
</intent-filter>
+
</activity-alias>
- <activity android:name=".ChooserActivity"
- android:theme="@style/Theme.DeviceDefault.Chooser"
- android:finishOnCloseSystemDialogs="true"
- android:excludeFromRecents="true"
- android:documentLaunchMode="never"
- android:relinquishTaskIdentity="true"
- android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
- android:visibleToInstantApps="true"
- android:exported="false"/>
+ <provider android:name="androidx.startup.InitializationProvider"
+ android:authorities="${applicationId}.androidx-startup"
+ tools:replace="android:authorities"
+ tools:node="remove" />
</application>
diff --git a/AndroidManifest-lib.xml b/AndroidManifest-lib.xml
index 509d46a5..bdb94232 100644
--- a/AndroidManifest-lib.xml
+++ b/AndroidManifest-lib.xml
@@ -31,4 +31,7 @@
<uses-permission android:name="android.permission.UNLIMITED_SHORTCUTS_API_CALLS" />
<uses-permission android:name="android.permission.QUERY_CLONED_APPS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+ <uses-permission android:name="android.permission.REPORT_USAGE_STATS" />
+ <uses-permission android:name="android.permission.LOG_COMPAT_CHANGE" />
+ <uses-permission android:name="android.permission.READ_COMPAT_CHANGE_CONFIG" />
</manifest>
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index d6456956..00000000
--- a/NOTICE
+++ /dev/null
@@ -1,202 +0,0 @@
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- 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.
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index d8136fec..edb73cd1 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -1,15 +1,13 @@
[Builtin Hooks]
-clang_format = true
+ktfmt = true
[Builtin Hooks Options]
-# Only turn on clang-format check for the following subfolders.
-clang_format = --commit ${PREUPLOAD_COMMIT} --style file --extensions c,h,cc,cpp
- jni/
- native/
+ktfmt = --kotlinlang-style
[Hook Scripts]
checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT}
-ktfmt_hook = ${REPO_ROOT}/external/ktfmt/ktfmt.py --check ${PREUPLOAD_FILES}
-
ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py --no-verify-format -f ${PREUPLOAD_FILES}
+
+[Tool Paths]
+ktfmt = ${REPO_ROOT}/external/ktfmt/ktfmt.sh
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..192f5ca1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# IntentResolver
+
+## About
+
+`IntentResolver` provides the implementation for Intent
+[ACTION_CHOOSER](https://developer.android.com/reference/android/content/Intent#ACTION_CHOOSER)
+
+See also: [ShareCompat.IntentBuilder](https://developer.android.com/reference/androidx/core/app/ShareCompat.IntentBuilder)
diff --git a/TEST_MAPPING b/TEST_MAPPING
index d142bb63..51c7bd43 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -1,7 +1,15 @@
{
"presubmit": [
{
- "name": "IntentResolverUnitTests"
+ "name": "IntentResolver-tests-unit"
+ },
+ {
+ "name": "IntentResolver-tests-activity"
+ }
+ ],
+ "postsubmit": [
+ {
+ "name": "IntentResolver-tests-integration"
}
]
}
diff --git a/aconfig/Android.bp b/aconfig/Android.bp
new file mode 100644
index 00000000..a56a8e2d
--- /dev/null
+++ b/aconfig/Android.bp
@@ -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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+aconfig_declarations {
+ name: "IntentResolverFlags",
+ package: "com.android.intentresolver",
+ container: "system",
+ srcs: ["FeatureFlags.aconfig"],
+}
+
+java_aconfig_library {
+ name: "IntentResolverFlagsLib",
+ aconfig_declarations: "IntentResolverFlags",
+}
diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig
new file mode 100644
index 00000000..788a22e2
--- /dev/null
+++ b/aconfig/FeatureFlags.aconfig
@@ -0,0 +1,118 @@
+package: "com.android.intentresolver"
+container: "system"
+
+# name: [a-z0-9][_a-z0-9]+
+# namespace: intentresolver
+# bug: "Feature_Bug_#" or "<none>"
+
+flag {
+ name: "announce_shortcuts_and_suggested_apps"
+ namespace: "intentresolver"
+ description: "Enable talkback announcement for the app shortcuts and the suggested apps target groups."
+ bug: "379208685"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "individual_metadata_title_read"
+ namespace: "intentresolver"
+ description: "Enables separate title URI metadata calls"
+ bug: "304686417"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "refine_system_actions"
+ namespace: "intentresolver"
+ description: "This flag enables sending system actions to the caller refinement flow"
+ bug: "331206205"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "fix_shortcuts_flashing_fixed"
+ namespace: "intentresolver"
+ description: "Do not flash shortcuts on payload selection change"
+ bug: "343300158"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "interactive_session"
+ namespace: "intentresolver"
+ description: "Enables interactive chooser session (a.k.a 'Splitti') feature."
+ bug: "358166090"
+}
+
+flag {
+ name: "keyboard_navigation_fix"
+ namespace: "intentresolver"
+ description: "Enable Chooser keyboard navigation bugfix"
+ bug: "325259478"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "rebuild_adapters_on_target_pinning"
+ namespace: "intentresolver"
+ description: "Rebuild and swap adapters when a target gets (un)pinned to avoid flickering."
+ bug: "230703572"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "target_hover_and_keyboard_focus_states"
+ namespace: "intentresolver"
+ description: "Adopt Launcher pointer hover and keyboard novigation focus effects for targets."
+ bug: "295175912"
+}
+
+flag {
+ name: "save_shareousel_state"
+ namespace: "intentresolver"
+ description: "Preserve Shareousel state over a system-initiated process death."
+ bug: "362347212"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
+}
+
+flag {
+ name: "shareousel_update_exclude_components_extra"
+ namespace: "intentresolver"
+ description: "Allow Shareousel selection change callback to update Intent#EXTRA_EXCLUDE_COMPONENTS"
+ bug: "352496527"
+}
+
+flag {
+ name: "unselect_final_item"
+ namespace: "intentresolver"
+ description: "Allow toggling of final Shareousel item"
+ bug: "349468879"
+}
+
+flag {
+ name: "shareousel_scroll_offscreen_selections"
+ namespace: "intentresolver"
+ description: "Whether to scroll items onscreen when they are partially offscreen and selected/unselected."
+ bug: "351883537"
+}
+
+flag {
+ name: "shareousel_selection_shrink"
+ namespace: "intentresolver"
+ description: "Whether to shrink Shareousel items when they are selected."
+ bug: "361792274"
+}
diff --git a/aconfig/README.md b/aconfig/README.md
new file mode 100644
index 00000000..87a6651c
--- /dev/null
+++ b/aconfig/README.md
@@ -0,0 +1,20 @@
+# AConfig Flag libraries
+
+Generated java flag libraries.
+
+### FeatureFlagsLib
+
+__Flags__
+* Static singleton provider for FeatureFlags impl
+* Overridable with setFeatureFlags/unsetFeatureFlags
+
+* __FeatureFlags__
+* The generated flags interface, one boolean function per flag
+
+__FeatureFlagsImpl__
+* For production code
+* Real implementation using DeviceConfig
+
+__FakeFeatureFlagsImpl__
+* a configurable stateful fake (get/set/clear)
+* Use with Dagger to inject across multiple components for integration tests
diff --git a/java/aidl/com/android/intentresolver/IChooserController.aidl b/java/aidl/com/android/intentresolver/IChooserController.aidl
new file mode 100644
index 00000000..a4ce718d
--- /dev/null
+++ b/java/aidl/com/android/intentresolver/IChooserController.aidl
@@ -0,0 +1,8 @@
+
+package com.android.intentresolver;
+
+import android.content.Intent;
+
+interface IChooserController {
+ oneway void updateIntent(in Intent intent);
+}
diff --git a/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl b/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl
new file mode 100644
index 00000000..4a6179d9
--- /dev/null
+++ b/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl
@@ -0,0 +1,9 @@
+
+package com.android.intentresolver;
+
+import com.android.intentresolver.IChooserController;
+
+interface IChooserInteractiveSessionCallback {
+ oneway void registerChooserController(in IChooserController updater);
+ oneway void onDrawerVerticalOffsetChanged(in int offset);
+}
diff --git a/java/res/color/resolver_profile_tab_text.xml b/java/res/color/resolver_profile_tab_text.xml
index 7c2723ce..f6a4eadf 100644
--- a/java/res/color/resolver_profile_tab_text.xml
+++ b/java/res/color/resolver_profile_tab_text.xml
@@ -15,6 +15,6 @@
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
- <item android:color="?androidprv:attr/materialColorOnPrimary" android:state_selected="true"/>
- <item android:color="?androidprv:attr/materialColorOnSurfaceVariant"/>
+ <item android:color="@androidprv:color/materialColorOnPrimary" android:state_selected="true"/>
+ <item android:color="@androidprv:color/materialColorOnSurface"/>
</selector>
diff --git a/java/res/drawable/bottomsheet_background.xml b/java/res/drawable/bottomsheet_background.xml
index f4386b7d..ec676cea 100644
--- a/java/res/drawable/bottomsheet_background.xml
+++ b/java/res/drawable/bottomsheet_background.xml
@@ -20,5 +20,5 @@
<corners
android:topLeftRadius="@*android:dimen/config_bottomDialogCornerRadius"
android:topRightRadius="@*android:dimen/config_bottomDialogCornerRadius"/>
- <solid android:color="?androidprv:attr/materialColorSurfaceContainer" />
+ <solid android:color="@androidprv:color/materialColorSurfaceContainer" />
</shape>
diff --git a/java/res/drawable/checkbox.xml b/java/res/drawable/checkbox.xml
new file mode 100644
index 00000000..189d01ff
--- /dev/null
+++ b/java/res/drawable/checkbox.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="20dp"
+ android:height="20dp"
+ android:viewportWidth="20"
+ android:viewportHeight="20">
+ <path
+ android:pathData="M10,0C4.48,0 0,4.48 0,10C0,15.52 4.48,20 10,20C15.52,20 20,15.52 20,10C20,4.48 15.52,0 10,0ZM10,18C5.59,18 2,14.41 2,10C2,5.59 5.59,2 10,2C14.41,2 18,5.59 18,10C18,14.41 14.41,18 10,18ZM5.4,9.6L8,12.2L14.6,5.6L16,7L8,15L4,11L5.4,9.6Z"
+ android:fillColor="#ffffff"
+ android:fillType="evenOdd"/>
+</vector>
diff --git a/java/res/drawable/chevron_right.xml b/java/res/drawable/chevron_right.xml
index 747e06dd..09fd97a7 100644
--- a/java/res/drawable/chevron_right.xml
+++ b/java/res/drawable/chevron_right.xml
@@ -26,7 +26,7 @@
android:viewportWidth="16"
android:viewportHeight="24"
android:autoMirrored="true"
- android:tint="?androidprv:attr/materialColorOnSurface">
+ android:tint="@androidprv:color/materialColorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M10,4.5L8.59,5.91 13.17,10.5l-4.58,4.59L10,16.5l6,-6 -6,-6z"/>
diff --git a/java/res/drawable/chooser_action_button_bg.xml b/java/res/drawable/chooser_action_button_bg.xml
index 300be831..88eac4ce 100644
--- a/java/res/drawable/chooser_action_button_bg.xml
+++ b/java/res/drawable/chooser_action_button_bg.xml
@@ -25,7 +25,7 @@
android:insetBottom="8dp">
<shape android:shape="rectangle">
<corners android:radius="@dimen/chooser_action_corner_radius" />
- <solid android:color="?androidprv:attr/materialColorSurfaceContainerHigh"/>
+ <solid android:color="@androidprv:color/materialColorSurfaceContainerHigh"/>
</shape>
</inset>
</item>
diff --git a/java/res/drawable/chooser_content_preview_rounded.xml b/java/res/drawable/chooser_content_preview_rounded.xml
index a1b204bd..00aa2912 100644
--- a/java/res/drawable/chooser_content_preview_rounded.xml
+++ b/java/res/drawable/chooser_content_preview_rounded.xml
@@ -21,7 +21,7 @@
android:shape="rectangle">
<solid
- android:color="?androidprv:attr/materialColorSurfaceContainerHigh" />
+ android:color="@androidprv:color/materialColorSurfaceContainerHigh" />
<corners android:radius="16dp" />
</shape>
diff --git a/java/res/drawable/chooser_direct_share_label_placeholder.xml b/java/res/drawable/chooser_direct_share_label_placeholder.xml
deleted file mode 100644
index b21444bf..00000000
--- a/java/res/drawable/chooser_direct_share_label_placeholder.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ 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
- -->
-<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
-
- <!-- This drawable is intended to be used as the background of a two line TextView. We only
- want the height to be ~1 line. Do this cheaply by applying padding to the bottom. -->
- <item android:bottom="18dp">
- <shape android:shape="rectangle" >
-
- <!-- Size used for scaling should the container be different dimensions -->
- <size android:width="@dimen/chooser_direct_share_label_placeholder_max_width"
- android:height="18dp"/>
-
- <!-- Absurd corner radius to ensure pill shape -->
- <corners android:bottomLeftRadius="100dp"
- android:bottomRightRadius="100dp"
- android:topLeftRadius="100dp"
- android:topRightRadius="100dp" />
-
- <solid android:color="@color/chooser_gradient_background "/>
- </shape>
- </item>
-</layer-list> \ No newline at end of file
diff --git a/java/res/drawable/chooser_row_layer_list.xml b/java/res/drawable/chooser_row_layer_list.xml
index 868ac8aa..2f1e2046 100644
--- a/java/res/drawable/chooser_row_layer_list.xml
+++ b/java/res/drawable/chooser_row_layer_list.xml
@@ -20,7 +20,7 @@
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
<item>
<shape android:shape="rectangle">
- <solid android:color="?androidprv:attr/materialColorSecondary"/>
+ <solid android:color="@androidprv:color/materialColorSecondary"/>
<size android:width="128dp" android:height="2dp"/>
<corners android:radius="2dp" />
</shape>
diff --git a/java/res/drawable/edit_action_background.xml b/java/res/drawable/edit_action_background.xml
index 91726f49..ebc6d814 100644
--- a/java/res/drawable/edit_action_background.xml
+++ b/java/res/drawable/edit_action_background.xml
@@ -22,7 +22,7 @@
<inset android:inset="8dp">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
- <solid android:color="?androidprv:attr/materialColorSecondaryFixed"/>
+ <solid android:color="@androidprv:color/materialColorSecondaryFixed"/>
</shape>
</inset>
</item>
diff --git a/java/res/drawable/ic_drag_handle.xml b/java/res/drawable/ic_drag_handle.xml
index f22e8c30..d6965209 100644
--- a/java/res/drawable/ic_drag_handle.xml
+++ b/java/res/drawable/ic_drag_handle.xml
@@ -17,6 +17,6 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:shape="rectangle" >
- <solid android:color="?androidprv:attr/materialColorOutlineVariant" />
+ <solid android:color="@androidprv:color/materialColorOutlineVariant" />
<corners android:radius="2dp" />
</shape>
diff --git a/java/res/drawable/ic_play_circle_filled_24px.xml b/java/res/drawable/ic_play_circle_filled_24px.xml
new file mode 100644
index 00000000..f67127ca
--- /dev/null
+++ b/java/res/drawable/ic_play_circle_filled_24px.xml
@@ -0,0 +1,3 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="20dp" android:viewportHeight="20" android:viewportWidth="20" android:width="20dp">
+ <path android:fillColor="#ffffff" android:fillType="evenOdd" android:pathData="M0,10C0,4.48 4.48,0 10,0C15.52,0 20,4.48 20,10C20,15.52 15.52,20 10,20C4.48,20 0,15.52 0,10ZM14,10L8,5.5V14.5L14,10Z"/>
+</vector>
diff --git a/java/res/drawable/inset_resolver_profile_tab_bg.xml b/java/res/drawable/inset_resolver_profile_tab_bg.xml
new file mode 100644
index 00000000..bc62b047
--- /dev/null
+++ b/java/res/drawable/inset_resolver_profile_tab_bg.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2024 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.
+ -->
+<inset xmlns:android="http://schemas.android.com/apk/res/android"
+ android:drawable="@drawable/resolver_profile_tab_bg"
+ android:insetLeft="0dp"
+ android:insetRight="0dp"
+ android:insetTop="6dp"
+ android:insetBottom="6dp" />
diff --git a/java/res/drawable/resolver_outlined_button_bg.xml b/java/res/drawable/resolver_outlined_button_bg.xml
index 3469a06e..b018624c 100644
--- a/java/res/drawable/resolver_outlined_button_bg.xml
+++ b/java/res/drawable/resolver_outlined_button_bg.xml
@@ -26,7 +26,7 @@
<shape android:shape="rectangle">
<corners android:radius="8dp" />
<stroke android:width="1dp"
- android:color="?androidprv:attr/materialColorPrimaryContainer"/>
+ android:color="@androidprv:color/materialColorPrimaryContainer"/>
</shape>
</inset>
</item>
diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml
index 8bb23a53..392f7e30 100644
--- a/java/res/drawable/resolver_profile_tab_bg.xml
+++ b/java/res/drawable/resolver_profile_tab_bg.xml
@@ -25,18 +25,18 @@
</item>
<item>
- <selector android:enterFadeDuration="100">
+ <selector>
<item android:state_selected="false">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
- <solid android:color="?androidprv:attr/materialColorSurfaceContainerHighest" />
+ <solid android:color="@androidprv:color/materialColorSurfaceBright" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
- <solid android:color="?androidprv:attr/materialColorPrimary" />
+ <solid android:color="@androidprv:color/materialColorPrimary" />
</shape>
</item>
</selector>
diff --git a/java/res/layout-h480dp/image_preview_image_item.xml b/java/res/layout-h480dp/image_preview_image_item.xml
index ac63b2d5..47dc7012 100644
--- a/java/res/layout-h480dp/image_preview_image_item.xml
+++ b/java/res/layout-h480dp/image_preview_image_item.xml
@@ -61,7 +61,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:background="@drawable/edit_action_background"
- android:drawableTint="?androidprv:attr/materialColorSecondaryFixed"
+ android:drawableTint="@androidprv:color/materialColorSecondaryFixed"
android:contentDescription="@string/screenshot_edit"
android:visibility="gone"
>
@@ -70,7 +70,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="4dp"
- android:tint="?androidprv:attr/materialColorOnSecondaryFixed"
+ android:tint="@androidprv:color/materialColorOnSecondaryFixed"
android:src="@androidprv:drawable/ic_screenshot_edit"
/>
</FrameLayout>
diff --git a/java/res/layout-h480dp/image_preview_loading_item.xml b/java/res/layout-h480dp/image_preview_loading_item.xml
index 85020e9a..0bc2656f 100644
--- a/java/res/layout-h480dp/image_preview_loading_item.xml
+++ b/java/res/layout-h480dp/image_preview_loading_item.xml
@@ -26,7 +26,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
- android:indeterminateTint="?androidprv:attr/materialColorPrimary"
+ android:indeterminateTint="@androidprv:color/materialColorPrimary"
android:indeterminateTintMode="src_in" />
</FrameLayout>
diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml
index 7bce113e..9b39ba67 100644
--- a/java/res/layout/chooser_action_row.xml
+++ b/java/res/layout/chooser_action_row.xml
@@ -30,7 +30,7 @@
android:layout_marginTop="8dp"
android:layout_marginHorizontal="@dimen/chooser_edge_margin_normal"
android:layout_marginBottom="10dp"
- android:background="?androidprv:attr/materialColorSurfaceContainerHighest"
+ android:background="@androidprv:color/materialColorSurfaceContainerHighest"
/>
</merge>
diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml
index e17dce0e..57cc59b7 100644
--- a/java/res/layout/chooser_action_view.xml
+++ b/java/res/layout/chooser_action_view.xml
@@ -14,7 +14,7 @@
~ limitations under the License
-->
-<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
style="?android:attr/borderlessButtonStyle"
android:background="@drawable/chooser_action_button_bg"
@@ -22,11 +22,10 @@
android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half"
android:clickable="true"
android:drawablePadding="6dp"
- android:drawableTint="?androidprv:attr/materialColorOnSurface"
+ android:drawableTint="@androidprv:color/materialColorOnSurface"
android:drawableTintMode="src_in"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
- android:maxWidth="@dimen/chooser_action_max_width"
- android:textColor="?androidprv:attr/materialColorOnSurface"
- android:textSize="12sp" />
+ android:textColor="@androidprv:color/materialColorOnSurface"
+ android:textSize="@dimen/chooser_action_view_text_size" />
diff --git a/java/res/layout/chooser_grid_item.xml b/java/res/layout/chooser_grid_item.xml
new file mode 100644
index 00000000..76d2e60f
--- /dev/null
+++ b/java/res/layout/chooser_grid_item.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2006, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+** http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+** See the License for the specific language governing permissions and
+** limitations under the License.
+*/
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:id="@androidprv:id/item"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="100dp"
+ android:gravity="top|center_horizontal"
+ android:paddingVertical="@dimen/grid_padding"
+ android:paddingHorizontal="4dp"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackgroundBorderless">
+
+ <ImageView android:id="@android:id/icon"
+ android:layout_width="@dimen/chooser_icon_size"
+ android:layout_height="@dimen/chooser_icon_size"
+ android:layout_marginHorizontal="8dp"
+ android:scaleType="fitCenter" />
+
+ <!-- Size manually tuned to match specs -->
+ <Space android:layout_width="1dp"
+ android:layout_height="7dp"/>
+
+ <!-- NOTE: for id/text1 and id/text2 below set the width to match parent as a workaround for
+ b/269395540 i.e. prevent views bounds change during a transition animation. It does not
+ affect pinned views as we change their layout parameters programmatically (but that's even
+ more narrow possibility and it's not clear if the root cause or the bug would affect it).
+ -->
+ <!-- App name or Direct Share target name, DS set to 2 lines -->
+ <com.android.intentresolver.widget.BadgeTextView
+ android:id="@android:id/text1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@androidprv:color/materialColorOnSurface"
+ android:textSize="@dimen/chooser_grid_target_name_text_size"
+ android:maxLines="1"
+ android:ellipsize="end" />
+
+ <!-- Activity name if set, gone for Direct Share targets -->
+ <TextView android:id="@android:id/text2"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textSize="@dimen/chooser_grid_activity_name_text_size"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:lines="1"
+ android:gravity="top|center_horizontal"
+ android:ellipsize="end"/>
+
+</LinearLayout>
+
diff --git a/java/res/layout/chooser_grid_item_hover.xml b/java/res/layout/chooser_grid_item_hover.xml
new file mode 100644
index 00000000..5e49c9fd
--- /dev/null
+++ b/java/res/layout/chooser_grid_item_hover.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2006, 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.
+*/
+-->
+<com.android.intentresolver.widget.ChooserTargetItemView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@androidprv:id/item"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="100dp"
+ android:gravity="top|center_horizontal"
+ android:paddingVertical="1dp"
+ android:paddingHorizontal="4dp"
+ android:focusable="true"
+ android:defaultFocusHighlightEnabled="false"
+ app:focusOutlineWidth="@dimen/chooser_item_focus_outline_width"
+ app:focusOutlineCornerRadius="@dimen/chooser_item_focus_outline_corner_radius"
+ app:focusOutlineColor="@androidprv:color/materialColorSecondaryFixed"
+ app:focusInnerOutlineColor="@androidprv:color/materialColorOnSecondaryFixedVariant">
+
+ <ImageView android:id="@android:id/icon"
+ android:layout_width="@dimen/chooser_icon_width_with_padding"
+ android:layout_height="@dimen/chooser_icon_height_with_padding"
+ android:paddingHorizontal="@dimen/chooser_icon_horizontal_padding"
+ android:paddingBottom="@dimen/chooser_icon_vertical_padding"
+ android:scaleType="fitCenter" />
+
+ <!-- NOTE: for id/text1 and id/text2 below set the width to match parent as a workaround for
+ b/269395540 i.e. prevent views bounds change during a transition animation. It does not
+ affect pinned views as we change their layout parameters programmatically (but that's even
+ more narrow possibility and it's not clear if the root cause or the bug would affect it).
+ -->
+ <!-- App name or Direct Share target name, DS set to 2 lines -->
+ <com.android.intentresolver.widget.BadgeTextView
+ android:id="@android:id/text1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@androidprv:color/materialColorOnSurface"
+ android:textSize="@dimen/chooser_grid_target_name_text_size"
+ android:maxLines="1"
+ android:ellipsize="end" />
+
+ <!-- Activity name if set, gone for Direct Share targets -->
+ <TextView android:id="@android:id/text2"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textSize="@dimen/chooser_grid_activity_name_text_size"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:lines="1"
+ android:gravity="top|center_horizontal"
+ android:ellipsize="end"/>
+
+</com.android.intentresolver.widget.ChooserTargetItemView>
diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml
index 3c836b4c..9584ec9a 100644
--- a/java/res/layout/chooser_grid_preview_file.xml
+++ b/java/res/layout/chooser_grid_preview_file.xml
@@ -24,9 +24,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:background="?androidprv:attr/materialColorSurfaceContainer">
-
- <include layout="@layout/chooser_headline_row"/>
+ android:background="@androidprv:color/materialColorSurfaceContainer">
<RelativeLayout
android:layout_width="match_parent"
@@ -65,7 +63,7 @@
android:gravity="start|top"
android:singleLine="true"
android:textStyle="bold"
- android:textColor="?androidprv:attr/materialColorOnSurface"
+ android:textColor="@androidprv:color/materialColorOnSurface"
android:textSize="12sp"
android:lineHeight="16sp"
android:textAppearance="@style/TextAppearance.ChooserDefault"/>
@@ -76,7 +74,7 @@
android:layout_height="wrap_content"
android:gravity="start|top"
android:singleLine="true"
- android:textColor="?androidprv:attr/materialColorOnSurfaceVariant"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
android:textSize="12sp"
android:lineHeight="16sp"
android:textAppearance="@style/TextAppearance.ChooserDefault"/>
diff --git a/java/res/layout/chooser_grid_preview_files_text.xml b/java/res/layout/chooser_grid_preview_files_text.xml
index c64d7ddd..9e2bde67 100644
--- a/java/res/layout/chooser_grid_preview_files_text.xml
+++ b/java/res/layout/chooser_grid_preview_files_text.xml
@@ -23,9 +23,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:background="?androidprv:attr/materialColorSurfaceContainer">
-
- <include layout="@layout/chooser_headline_row" />
+ android:background="@androidprv:color/materialColorSurfaceContainer">
<LinearLayout
android:layout_width="match_parent"
@@ -55,6 +53,7 @@
android:maxLines="@integer/text_preview_lines"
android:ellipsize="end"
android:linksClickable="false"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
android:textAppearance="@style/TextAppearance.ChooserDefault"/>
</LinearLayout>
diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml
index 4a832324..199963b1 100644
--- a/java/res/layout/chooser_grid_preview_image.xml
+++ b/java/res/layout/chooser_grid_preview_image.xml
@@ -24,9 +24,15 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:importantForAccessibility="no"
- android:background="?androidprv:attr/materialColorSurfaceContainer">
+ android:background="@androidprv:color/materialColorSurfaceContainer">
- <include layout="@layout/chooser_headline_row"/>
+ <ViewStub
+ android:id="@+id/chooser_headline_row_stub"
+ android:layout="@layout/chooser_headline_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/chooser_edge_margin_normal"
+ android:layout_marginBottom="@dimen/chooser_view_spacing" />
<com.android.intentresolver.widget.ScrollableImagePreviewView
android:id="@+id/scrollable_image_preview"
@@ -35,7 +41,8 @@
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"
app:itemInnerSpacing="3dp"
- app:itemOuterSpacing="@dimen/chooser_edge_margin_normal"/>
+ app:itemOuterSpacing="@dimen/chooser_edge_margin_normal"
+ app:editButtonRoleDescription="@string/role_description_button"/>
<include layout="@layout/chooser_action_row"/>
</LinearLayout>
diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml
index df906cce..951abfc7 100644
--- a/java/res/layout/chooser_grid_preview_text.xml
+++ b/java/res/layout/chooser_grid_preview_text.xml
@@ -25,9 +25,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:background="?androidprv:attr/materialColorSurfaceContainer">
-
- <include layout="@layout/chooser_headline_row" />
+ android:background="@androidprv:color/materialColorSurfaceContainer">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@@ -69,7 +67,7 @@
android:textAlignment="gravity"
android:textDirection="locale"
android:textStyle="bold"
- android:textColor="?androidprv:attr/materialColorOnSurface"
+ android:textColor="@androidprv:color/materialColorOnSurface"
android:fontFamily="@androidprv:string/config_headlineFontFamily"/>
<TextView
@@ -84,7 +82,7 @@
app:layout_goneMarginStart="0dp"
android:ellipsize="end"
android:fontFamily="@androidprv:string/config_headlineFontFamily"
- android:textColor="?androidprv:attr/materialColorOnSurfaceVariant"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
android:textAlignment="gravity"
android:textDirection="locale"
android:maxLines="@integer/text_preview_lines"
@@ -107,7 +105,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
- android:tint="?androidprv:attr/materialColorOnSurfaceVariant"
+ android:tint="@androidprv:color/materialColorOnSurfaceVariant"
android:src="@androidprv:drawable/ic_menu_copy_material"
/>
</FrameLayout>
@@ -116,4 +114,3 @@
<include layout="@layout/chooser_action_row" />
</LinearLayout>
-
diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid_scrollable_preview.xml
index 8320b284..f8c7a541 100644
--- a/java/res/layout/chooser_grid.xml
+++ b/java/res/layout/chooser_grid_scrollable_preview.xml
@@ -25,6 +25,7 @@
android:layout_gravity="center"
app:maxCollapsedHeight="0dp"
app:maxCollapsedHeightSmall="56dp"
+ app:useScrollablePreviewNestedFlingLogic="true"
android:maxWidth="@dimen/chooser_width"
android:id="@androidprv:id/contentPanel">
@@ -60,38 +61,68 @@
</RelativeLayout>
<FrameLayout
- android:id="@androidprv:id/content_preview_container"
+ android:id="@+id/chooser_headline_row_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:visibility="gone" />
+ app:layout_alwaysShow="true"
+ android:background="@androidprv:color/materialColorSurfaceContainer">
+
+ <ViewStub
+ android:id="@+id/chooser_headline_row_stub"
+ android:inflatedId="@+id/chooser_headline_row"
+ android:layout="@layout/chooser_headline_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/chooser_edge_margin_normal"
+ android:layout_marginBottom="@dimen/chooser_view_spacing" />
+ </FrameLayout>
- <TabHost
- android:id="@androidprv:id/profile_tabhost"
+ <com.android.intentresolver.widget.ChooserNestedScrollView
+ android:id="@+id/chooser_scrollable_container"
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_alignParentTop="true"
- android:layout_centerHorizontal="true"
- android:background="?androidprv:attr/materialColorSurfaceContainer">
+ android:layout_height="wrap_content">
+
<LinearLayout
- android:orientation="vertical"
android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <TabWidget
- android:id="@android:id/tabs"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <FrameLayout
+ android:id="@androidprv:id/content_preview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:visibility="gone">
- </TabWidget>
- <FrameLayout
- android:id="@android:id/tabcontent"
+ android:visibility="gone" />
+
+ <TabHost
+ android:id="@androidprv:id/profile_tabhost"
android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <com.android.intentresolver.ResolverViewPager
- android:id="@androidprv:id/profile_pager"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_centerHorizontal="true"
+ android:background="@androidprv:color/materialColorSurfaceContainer">
+ <LinearLayout
+ android:orientation="vertical"
android:layout_width="match_parent"
- android:layout_height="wrap_content"/>
- </FrameLayout>
- </LinearLayout>
- </TabHost>
+ android:layout_height="wrap_content">
+ <TabWidget
+ android:id="@android:id/tabs"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone">
+ </TabWidget>
+ <FrameLayout
+ android:id="@android:id/tabcontent"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <com.android.intentresolver.ResolverViewPager
+ android:id="@androidprv:id/profile_pager"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"/>
+ </FrameLayout>
+ </LinearLayout>
+ </TabHost>
+ </LinearLayout>
+
+ </com.android.intentresolver.widget.ChooserNestedScrollView>
</com.android.intentresolver.widget.ResolverDrawerLayout>
diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml
index 62781847..1c8a0ac9 100644
--- a/java/res/layout/chooser_headline_row.xml
+++ b/java/res/layout/chooser_headline_row.xml
@@ -35,7 +35,22 @@
app:layout_constrainedWidth="true"
style="@style/TextAppearance.ChooserDefault"
android:fontFamily="@androidprv:string/config_headlineFontFamily"
- android:textSize="18sp"
+ android:textSize="@dimen/chooser_headline_text_size"
+ />
+
+ <TextView
+ android:id="@+id/metadata"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/barrier"
+ app:layout_constraintHorizontal_bias="0.0"
+ app:layout_constrainedWidth="true"
+ app:layout_constraintTop_toBottomOf="@id/headline"
+ style="@style/TextAppearance.ChooserDefault"
+ android:fontFamily="@androidprv:string/config_bodyFontFamily"
+ android:textSize="12sp"
/>
<androidx.constraintlayout.widget.Barrier
@@ -45,18 +60,22 @@
app:barrierDirection="start"
app:constraint_referenced_ids="reselection_action,include_text_action" />
- <TextView
+ <Button
android:id="@+id/reselection_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="@dimen/modify_share_text_toggle_max_width"
+ android:background="@drawable/chooser_action_button_bg"
app:layout_constraintEnd_toEndOf="parent"
android:maxLines="2"
android:ellipsize="end"
android:visibility="gone"
- android:paddingTop="3dp"
- style="@style/TextAppearance.ChooserDefault"
+ android:paddingVertical="3dp"
+ android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half"
+ style="?android:attr/borderlessButtonStyle"
android:drawableEnd="@drawable/chevron_right"
+ android:textColor="@androidprv:color/materialColorOnSurface"
+ android:textSize="12sp"
/>
<!-- This is only relevant for image+text preview, but needs to be in this layout so it can
@@ -71,7 +90,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/reselection_action"
android:layout_alignWithParentIfMissing="true"
- android:textColor="?androidprv:attr/materialColorOnSurface"
+ android:textColor="@androidprv:color/materialColorOnSurface"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile_wrap.xml
index ef82090c..e556bc94 100644
--- a/java/res/layout/chooser_list_per_profile.xml
+++ b/java/res/layout/chooser_list_per_profile_wrap.xml
@@ -18,16 +18,16 @@
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
- android:layout_height="match_parent">
+ android:layout_height="wrap_content">
+
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
- android:layout_height="match_parent"
+ android:layout_height="wrap_content"
app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager"
android:id="@androidprv:id/resolver_list"
android:clipToPadding="false"
- android:background="?androidprv:attr/materialColorSurfaceContainer"
+ android:background="@androidprv:color/materialColorSurfaceContainer"
android:scrollbars="none"
- android:elevation="1dp"
android:nestedScrollingEnabled="true" />
<include layout="@layout/resolver_empty_states" />
diff --git a/java/res/layout/chooser_row.xml b/java/res/layout/chooser_row.xml
index 4a5e28c3..3fe1ee7d 100644
--- a/java/res/layout/chooser_row.xml
+++ b/java/res/layout/chooser_row.xml
@@ -18,6 +18,7 @@
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:id="@+id/suggested_apps_container"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="100dp"
@@ -28,7 +29,7 @@
android:layout_height="wrap_content"
android:gravity="center"
android:layout_gravity="center"
- android:textColor="?androidprv:attr/materialColorOnSurfaceVariant"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>
diff --git a/java/res/layout/chooser_row_direct_share.xml b/java/res/layout/chooser_row_direct_share.xml
index d7e36eed..53e666a6 100644
--- a/java/res/layout/chooser_row_direct_share.xml
+++ b/java/res/layout/chooser_row_direct_share.xml
@@ -17,6 +17,7 @@
*/
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/shortcuts_container"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="200dp">
diff --git a/java/res/layout/image_preview_loading_item.xml b/java/res/layout/image_preview_loading_item.xml
index a8a8f264..edcfb3d1 100644
--- a/java/res/layout/image_preview_loading_item.xml
+++ b/java/res/layout/image_preview_loading_item.xml
@@ -27,7 +27,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
- android:indeterminateTint="?androidprv:attr/materialColorPrimary"
+ android:indeterminateTint="@androidprv:color/materialColorPrimary"
android:indeterminateTintMode="src_in" />
</FrameLayout>
diff --git a/java/res/layout/resolve_grid_item.xml b/java/res/layout/resolve_grid_item.xml
index 25088773..f9d433de 100644
--- a/java/res/layout/resolve_grid_item.xml
+++ b/java/res/layout/resolve_grid_item.xml
@@ -49,8 +49,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
- android:textColor="?androidprv:attr/materialColorOnSurface"
- android:textSize="12sp"
+ android:textColor="@androidprv:color/materialColorOnSurface"
+ android:textSize="@dimen/chooser_grid_target_name_text_size"
android:gravity="top|center_horizontal"
android:maxLines="1"
android:ellipsize="end" />
@@ -58,8 +58,8 @@
<!-- Activity name if set, gone for Direct Share targets -->
<TextView android:id="@android:id/text2"
android:textAppearance="?android:attr/textAppearanceSmall"
- android:textSize="12sp"
- android:textColor="?androidprv:attr/materialColorOnSurfaceVariant"
+ android:textSize="@dimen/chooser_grid_activity_name_text_size"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lines="1"
diff --git a/java/res/layout/resolver_empty_states.xml b/java/res/layout/resolver_empty_states.xml
index d77630ee..4dac23ab 100644
--- a/java/res/layout/resolver_empty_states.xml
+++ b/java/res/layout/resolver_empty_states.xml
@@ -79,13 +79,13 @@
android:layout_centerHorizontal="true"
android:layout_below="@androidprv:id/resolver_empty_state_subtitle"
android:indeterminateTint="?android:attr/colorAccent"/>
+ <TextView
+ android:id="@android:id/empty"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/noApplications"
+ android:textColor="@androidprv:color/materialColorOnSurfaceVariant"
+ android:padding="@dimen/chooser_edge_margin_normal"
+ android:gravity="center"/>
</RelativeLayout>
- <TextView android:id="@android:id/empty"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:background="?android:attr/colorBackground"
- android:text="@string/noApplications"
- android:padding="@dimen/chooser_edge_margin_normal"
- android:layout_marginBottom="56dp"
- android:gravity="center"/>
</RelativeLayout>
diff --git a/java/res/layout/resolver_profile_tab_button.xml b/java/res/layout/resolver_profile_tab_button.xml
index 1c2bc1ca..7404dc33 100644
--- a/java/res/layout/resolver_profile_tab_button.xml
+++ b/java/res/layout/resolver_profile_tab_button.xml
@@ -17,13 +17,11 @@
<Button
xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:layout_width="0dp"
- android:layout_height="36dp"
+ android:layout_height="48dp"
android:layout_weight="1"
- android:layout_marginVertical="6dp"
android:layout_marginHorizontal="@dimen/resolver_profile_tab_margin"
- android:background="@drawable/resolver_profile_tab_bg"
+ android:background="@drawable/inset_resolver_profile_tab_bg"
android:textColor="@color/resolver_profile_tab_text"
android:textSize="@dimen/resolver_tab_text_size"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.DialogWindowTitle"
diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml
index 23dca362..a0b78850 100644
--- a/java/res/values-af/strings.xml
+++ b/java/res/values-af/strings.xml
@@ -36,17 +36,17 @@
<string name="whichSendToApplication" msgid="2724450540348806267">"Stuur met"</string>
<string name="whichSendToApplicationNamed" msgid="1996548940365954543">"Stuur met <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichSendToApplicationLabel" msgid="6909037198280591110">"Stuur"</string>
- <string name="whichHomeApplication" msgid="8797832422254564739">"Kies \'n Tuis-program"</string>
+ <string name="whichHomeApplication" msgid="8797832422254564739">"Kies \'n Tuis-app"</string>
<string name="whichHomeApplicationNamed" msgid="3943122502791761387">"Gebruik <xliff:g id="APP">%1$s</xliff:g> as Tuis"</string>
<string name="whichHomeApplicationLabel" msgid="2066319585322981524">"Vang prent vas"</string>
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"Vang prent vas met"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"Vang prent vas met <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"Vang prent vas"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"Gebruik \'n ander program"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"Gebruik ’n ander app"</string>
<string name="chooseActivity" msgid="6659724877523973446">"Kies \'n handeling"</string>
<string name="noApplications" msgid="1139487441772284671">"Geen programme kan hierdie handeling uitvoer nie."</string>
- <string name="forward_intent_to_owner" msgid="6454987608971162379">"Jy gebruik hierdie program buite jou werkprofiel"</string>
- <string name="forward_intent_to_work" msgid="2906094223089139419">"Jy gebruik tans hierdie program in jou werkprofiel"</string>
+ <string name="forward_intent_to_owner" msgid="6454987608971162379">"Jy gebruik hierdie app buite jou werkprofiel"</string>
+ <string name="forward_intent_to_work" msgid="2906094223089139419">"Jy gebruik tans hierdie app in jou werkprofiel"</string>
<string name="activity_resolver_use_always" msgid="8674194687637555245">"Altyd"</string>
<string name="activity_resolver_use_once" msgid="594173435998892989">"Net een keer"</string>
<string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"<xliff:g id="APP">%1$s</xliff:g> steun nie werkprofiel nie"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deel tans prent}other{Deel tans # prente}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deel tans video}other{Deel tans # video’s}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deel tans # lêer}other{Deel tans # lêers}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Kies items om te deel"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deel tans prent met teks}other{Deel tans # prente met teks}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deel tans prent met skakel}other{Deel tans # prente met skakel}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deel tans video met teks}other{Deel tans # video’s met teks}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deel tans video met skakel}other{Deel tans # video’s met skakel}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deel tans lêer met teks}other{Deel tans # lêers met teks}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deel tans lêer met skakel}other{Deel tans # lêers met skakel}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deel tans album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Net prent}other{Net prente}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Net video}other{Net video’s}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Net lêer}other{Net lêers}}"</string>
@@ -73,20 +75,25 @@
<string name="video_preview_a11y_description" msgid="683440858811095990">"Videovoorskouminiprent"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Lêervoorskouminiprent"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Geen mense om mee te deel is aanbeveel nie"</string>
- <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Opneemtoestemming is nie aan hierdie program verleen nie, maar dit kan oudio deur hierdie USB-toestel opneem."</string>
+ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Opneemtoestemming is nie aan hierdie app verleen nie, maar dit kan oudio deur hierdie USB-toestel opneem."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlik"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privaat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlike aansig"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkaansig"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaat aansig"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Deur jou IT-admin geblokkeer"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hierdie inhoud kan nie met werkprogramme gedeel word nie"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hierdie inhoud kan nie met werkprogramme oopgemaak word nie"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hierdie inhoud kan nie met persoonlike apps gedeel word nie"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hierdie inhoud kan nie met persoonlike programme oopgemaak word nie"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hierdie inhoud kan nie met privaat apps gedeel word nie"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hierdie inhoud kan nie met privaat apps oopgemaak word nie"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werkapps word onderbreek"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervat"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werkprogramme nie"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlike programme nie"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen private apps nie"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Maak <xliff:g id="APP">%s</xliff:g> in jou persoonlike profiel oop?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Maak <xliff:g id="APP">%s</xliff:g> in jou werkprofiel oop?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gebruik persoonlike blaaier"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Sluit teks in"</string>
<string name="exclude_link" msgid="1332778255031992228">"Sluit skakel uit"</string>
<string name="include_link" msgid="827855767220339802">"Sluit skakel in"</string>
+ <string name="pinned" msgid="7623664001331394139">"Vasgespeld"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Kiesbare prent"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Kiesbare video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Kiesbare item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Knoppie"</string>
</resources>
diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml
index d381cfd1..d46f88d1 100644
--- a/java/res/values-am/strings.xml
+++ b/java/res/values-am/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ምስልን በማጋራት ላይ}one{# ምስልን በማጋራት ላይ}other{# ምስሎችን በማጋራት ላይ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ቪድዮ በማጋራት ላይ}one{# ቪድዮ በማጋራት ላይ}other{# ቪድዮዎችን በማጋራት ላይ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ፋይልን በማጋራት ላይ}one{# ፋይልን በማጋራት ላይ}other{# ፋይሎችን በማጋራት ላይ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ለማጋራት ንጥሎችን ምረጥ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ምስልን ከጽሑፍ ጋር በማጋራት ላይ}one{# ምስልን ከጽሑፍ ጋር በማጋራት ላይ}other{# ምስሎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ምስልን ከአገናኝ ጋር በማጋራት ላይ}one{# ምስልን ከአገናኝ ጋር በማጋራት ላይ}other{# ምስሎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ቪድዮ ከጽሑፍ ጋር በማጋራት ላይ}one{# ቪድዮ ከጽሑፍ ጋር በማጋራት ላይ}other{# ቪድዮዎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}one{# ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}other{# ቪድዮዎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}one{# ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}other{# ፋይሎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ፋይልን ከአገናኝ ጋር በማጋራት ላይ}one{# ፋይልን ከአገናኝ ጋር በማጋራት ላይ}other{# ፋይሎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"የተጋራ አልበም"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ምስል ብቻ}one{ምስል ብቻ}other{ምስሎች ብቻ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ቪድዮ ብቻ}one{ቪድዮ ብቻ}other{ቪድዮዎች ብቻ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ፋይል ብቻ}one{ፋይል ብቻ}other{ፋይሎች ብቻ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ይህ መተግበሪያ የመቅረጽ ፈቃድ አልተሰጠውም፣ ነገር ግን በዚህ ዩኤስቢ መሣሪያ በኩል ኦዲዮን መቅረጽ ይችላል።"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"የግል"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ሥራ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"የግል"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"የግል ዕይታ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"የስራ ዕይታ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"የግል ዕይታ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"በእርስዎ የአይቲ አስተዳዳሪ ታግዷል"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ይህ ይዘት በሥራ መተግበሪያዎች መጋራት አይችልም"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ይህ ይዘት በሥራ መተግበሪያዎች መከፈት አይችልም"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ይህ ይዘት በግል መተግበሪያዎች መጋራት አይችልም"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ይህ ይዘት በግል መተግበሪያዎች መከፈት አይችልም"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ይህ ይዘት በግል መተግበሪያዎች ሊጋራ አይችልም"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ይህ ይዘት በግል መተግበሪያዎች ሊከፈት አይችልም"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"የሥራ መተግበሪያዎች ባሉበት ቆመዋል"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ከቆመበት ቀጥል"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ምንም የሥራ መተግበሪያዎች የሉም"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ምንም የግል መተግበሪያዎች የሉም"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ምንም የግል መተግበሪያዎች የሉም"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> በግል መገለጫዎ ውስጥ ይከፈት?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> በስራ መገለጫዎ ውስጥ ይከፈት?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"የግል አሳሽ ተጠቀም"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ጽሁፍ ጨምር"</string>
<string name="exclude_link" msgid="1332778255031992228">"አገናኝን አታካትት"</string>
<string name="include_link" msgid="827855767220339802">"አገናኝ አካትት"</string>
+ <string name="pinned" msgid="7623664001331394139">"ፒን ተደርጓል"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ሊመረጥ የሚችል ምስል"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ሊመረጥ የሚችል ቪድዮ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ሊመረጥ የሚችል ንጥል"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"አዝራር"</string>
</resources>
diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml
index 4e91eedf..278e03f2 100644
--- a/java/res/values-ar/strings.xml
+++ b/java/res/values-ar/strings.xml
@@ -45,11 +45,11 @@
<string name="use_a_different_app" msgid="2062380818535918975">"استخدام تطبيق آخر"</string>
<string name="chooseActivity" msgid="6659724877523973446">"اختيار إجراء"</string>
<string name="noApplications" msgid="1139487441772284671">"ليست هناك تطبيقات يمكنها تنفيذ هذا الإجراء."</string>
- <string name="forward_intent_to_owner" msgid="6454987608971162379">"أنت تستخدم هذا التطبيق خارج ملفك الشخصي للعمل"</string>
- <string name="forward_intent_to_work" msgid="2906094223089139419">"أنت تستخدم هذا التطبيق في ملفك الشخصي للعمل"</string>
+ <string name="forward_intent_to_owner" msgid="6454987608971162379">"أنت تستخدم هذا التطبيق خارج ملف العمل الخاص بك"</string>
+ <string name="forward_intent_to_work" msgid="2906094223089139419">"أنت تستخدم هذا التطبيق في ملف العمل الخاص بك"</string>
<string name="activity_resolver_use_always" msgid="8674194687637555245">"دائمًا"</string>
<string name="activity_resolver_use_once" msgid="594173435998892989">"مرة واحدة فقط"</string>
- <string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"لا يتوافق تطبيق \"<xliff:g id="APP">%1$s</xliff:g>\" مع الملف الشخصي للعمل."</string>
+ <string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"لا يتوافق تطبيق \"<xliff:g id="APP">%1$s</xliff:g>\" مع ملف العمل."</string>
<string name="pin_specific_target" msgid="5057063421361441406">"تثبيت <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="unpin_specific_target" msgid="3115158908159857777">"إزالة تثبيت <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="screenshot_edit" msgid="3857183660047569146">"تعديل"</string>
@@ -60,39 +60,51 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{جارٍ مشاركة صورة واحدة}zero{جارٍ مشاركة # صورة}two{جارٍ مشاركة صورتَين}few{جارٍ مشاركة # صور}many{جارٍ مشاركة # صورة}other{جارٍ مشاركة # صورة}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{جارٍ مشاركة فيديو واحد}zero{جارٍ مشاركة # فيديو}two{جارٍ مشاركة فيديوهَين}few{جارٍ مشاركة # فيديوهات}many{جارٍ مشاركة # فيديو}other{جارٍ مشاركة # فيديو}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{مشاركة ملف واحد}zero{مشاركة # ملف}two{مشاركة ملفَّين}few{مشاركة # ملفات}many{مشاركة # ملفًّا}other{مشاركة # ملف}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"اختيار العناصر المراد مشاركتها"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{مشاركة صورة واحدة ونص}zero{مشاركة # صورة ونص}two{مشاركة صورتَين ونص}few{مشاركة # صور ونص}many{مشاركة # صورة ونص}other{مشاركة # صورة ونص}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{مشاركة صورة واحدة ورابط}zero{مشاركة # صورة ورابط}two{مشاركة # صورتَين ورابط}few{مشاركة # صور ورابط}many{مشاركة # صورة ورابط}other{مشاركة # صورة ورابط}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{مشاركة فيديو واحد ونص}zero{مشاركة # فيديو ونص}two{مشاركة فيديوهَين ونص}few{مشاركة # فيديوهات ونص}many{مشاركة # فيديو ونص}other{مشاركة # فيديو ونص}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{مشاركة فيديو واحد ورابط}zero{مشاركة # فيديو ورابط}two{مشاركة فيديوهَين ورابط}few{مشاركة # فيديوهات ورابط}many{مشاركة # فيديو ورابط}other{مشاركة # فيديو ورابط}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{مشاركة ملف واحد ونص}zero{مشاركة # ملف ونص}two{مشاركة # ملفَّين ونص}few{مشاركة # ملفات ونص}many{مشاركة # ملفًا ونص}other{مشاركة # ملف ونص}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{مشاركة ملف واحد ورابط}zero{مشاركة # ملف ورابط}two{مشاركة ملفَّين ورابط}few{مشاركة # ملفات ورابط}many{مشاركة # ملفًا ورابط}other{مشاركة # ملف ورابط}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"مشاركة الألبوم"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{الصورة فقط}zero{الصور فقط}two{الصورتان فقط}few{الصور فقط}many{الصور فقط}other{الصور فقط}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{الفيديو فقط}zero{الفيديوهات فقط}two{الفيديوهان فقط}few{الفيديوهات فقط}many{الفيديوهات فقط}other{الفيديوهات فقط}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{الملف فقط}zero{الملفات فقط}two{الملفان فقط}few{الملفات فقط}many{الملفات فقط}other{الملفات فقط}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"صورة مصغّرة لمعاينة صورة"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"صورة مصغّرة لمعاينة فيديو"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"صورة مصغّرة لمعاينة ملف"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"ما مِن أشخاص مقترحين للمشاركة معهم."</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"ما مِن أشخاص مقترحين للمشاركة معهم"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"‏لم يتم منح هذا التطبيق إذن تسجيل، ولكن يمكنه تسجيل الصوت من خلال جهاز USB هذا."</string>
- <string name="resolver_personal_tab" msgid="1381052735324320565">"شخصي"</string>
- <string name="resolver_work_tab" msgid="3588325717455216412">"للعمل"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"المساحة الشخصية"</string>
+ <string name="resolver_work_tab" msgid="3588325717455216412">"مساحة العمل"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"المساحة الخاصّة"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"عرض المحتوى الشخصي"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"عرض محتوى العمل"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"عرض المساحة الخاصّة"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"حظر مشرف تكنولوجيا المعلومات مشاركة المحتوى"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"لا يمكن مشاركة هذا المحتوى مع تطبيقات العمل."</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"لا يمكن فتح هذا المحتوى باستخدام تطبيقات العمل."</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الشخصية."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الشخصية."</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الخاصة"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الخاصة"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"تطبيقات العمل متوقفة مؤقتًا."</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"إلغاء الإيقاف المؤقت"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ما مِن تطبيقات عمل."</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ما مِن تطبيقات شخصية."</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ما مِن تطبيقات خاصة"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي؟"</string>
- <string name="miniresolver_open_in_work" msgid="4271638122142624693">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي للعمل؟"</string>
+ <string name="miniresolver_open_in_work" msgid="4271638122142624693">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملف العمل الخاص بك؟"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استخدام المتصفّح الشخصي"</string>
<string name="miniresolver_use_work_browser" msgid="7892699758493230342">"استخدام متصفّح العمل"</string>
<string name="exclude_text" msgid="5508128757025928034">"استثناء النص"</string>
<string name="include_text" msgid="642280283268536140">"تضمين النص"</string>
<string name="exclude_link" msgid="1332778255031992228">"استثناء الرابط"</string>
<string name="include_link" msgid="827855767220339802">"تضمين الرابط"</string>
+ <string name="pinned" msgid="7623664001331394139">"مثبَّت"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"صورة يمكن اختيارها"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"فيديو يمكن اختياره"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"عنصر يمكن اختياره"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"زرّ"</string>
</resources>
diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml
index 93d8d10f..2177c527 100644
--- a/java/res/values-as/strings.xml
+++ b/java/res/values-as/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{# খন প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{# খন প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{# টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{# টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"শ্বেয়াৰ কৰাৰ বাবে বস্তু বাছনি কৰক"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{পাঠৰ সৈতে প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{লিংকৰ সৈতে প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{পাঠৰ সৈতে ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিংকৰ সৈতে ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{পাঠৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিংকৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"এলবাম শ্বেয়াৰ কৰি থকা হৈছে"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{কেৱল প্ৰতিচ্ছবি}one{কেৱল প্ৰতিচ্ছবিসমূহ}other{কেৱল প্ৰতিচ্ছবিসমূহ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{কেৱল ভিডিঅ’}one{কেৱল ভিডিঅ’সমূহ}other{কেৱল ভিডিঅ’সমূহ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{কেৱল ফাইল}one{কেৱল ফাইলসমূহ}other{কেৱল ফাইলসমূহ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই এপ্‌টোক ৰেকর্ড কৰাৰ অনুমতি দিয়া হোৱা নাই কিন্তু ই এই ইউএছবি ডিভাইচটোৰ জৰিয়তে অডিঅ\' ৰেকর্ড কৰিব পাৰে।"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"কৰ্মস্থান"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ব্যক্তিগত"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"কৰ্মস্থানৰ ভিউ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপোনাৰ আইটি প্ৰশাসকে অৱৰোধ কৰিছে"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"এই সমল কৰ্মস্থানৰ এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"এই সমল কৰ্মস্থানৰ এপৰ জৰিয়তে খুলিব নোৱাৰি"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"কাম সম্পর্কীয় এপ্‌ পজ কৰা আছে"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ কৰক"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"কোনো কৰ্মস্থানৰ এপ্‌ নাই"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"কোনো ব্যক্তিগত এপ্‌ নাই"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনো ব্যক্তিগত এপ্‌নাই"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপোনাৰ ব্যক্তিগত প্ৰ’ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপোনাৰ কর্মস্থানৰ প্ৰ\'ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্ৰাউজাৰ ব্যৱহাৰ কৰক"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"পাঠ অন্তৰ্ভুক্ত কৰক"</string>
<string name="exclude_link" msgid="1332778255031992228">"লিংক বহিৰ্ভূত কৰক"</string>
<string name="include_link" msgid="827855767220339802">"লিংক অন্তৰ্ভুক্ত কৰক"</string>
+ <string name="pinned" msgid="7623664001331394139">"পিন কৰা আছে"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"বাছনি কৰিব পৰা প্ৰতিচ্ছবি"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"বাছনি কৰিব পৰা ভিডিঅ’"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"বাছনি কৰিব পৰা বস্তু"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"বুটাম"</string>
</resources>
diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml
index 8e58d593..93086938 100644
--- a/java/res/values-az/strings.xml
+++ b/java/res/values-az/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Şəkil paylaşılır}other{# şəkil paylaşılır}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video paylaşılır}other{# video paylaşılır}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fayl paylaşılır}other{# fayl paylaşılır}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Paylaşmaq üçün elementlər seçin"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Mətn olan şəkil paylaşılır}other{Mətn olan # şəkil paylaşılır}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Link olan şəkil paylaşılır}other{Link olan # şəkil paylaşılır}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Mətn olan video paylaşılır}other{Mətn olan # video paylaşılır}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Link olan video paylaşılır}other{Link olan # video paylaşılır}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Mətn olan fayl paylaşılır}other{Mətn olan # fayl paylaşılır}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Link olan fayl paylaşılır}other{Link olan # fayl paylaşılır}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albom paylaşılır"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnız şəkil}other{Yalnız şəkillər}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnız video}other{Yalnız videolar}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnız fayl}other{Yalnız fayllar}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tətbiqə qeydə almaq icazəsi verilməsə də, bu USB vasitəsilə səsi qeydə ala bilər."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Şəxsi"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Məxfi"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Şəxsi məzmuna baxış"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş məzmununa baxış"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Şəxsi baxış"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT admininiz tərəfindən bloklanıb"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontenti iş tətbiqləri ilə paylaşmaq mümkün deyil"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontenti iş tətbiqləri ilə açmaq mümkün deyil"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontenti şəxsi tətbiqlər ilə paylaşmaq mümkün deyil"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontenti şəxsi tətbiqlər ilə açmaq mümkün deyil"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontenti şəxsi tətbiqlərlə paylaşmaq mümkün deyil"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontenti şəxsi tətbiqlərlə açmaq mümkün deyil"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş tətbiqləri durdurulub"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Pauzanı bitirin"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş tətbiqi yoxdur"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Şəxsi tətbiq yoxdur"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Şəxsi tətbiq yoxdur"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Şəxsi profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"İş profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Şəxsi brauzerdən istifadə edin"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Mətn daxil edin"</string>
<string name="exclude_link" msgid="1332778255031992228">"Keçidi istisna edin"</string>
<string name="include_link" msgid="827855767220339802">"Keçid daxil edin"</string>
+ <string name="pinned" msgid="7623664001331394139">"Bərkidilib"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Seçilə bilən şəkil"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Seçilə bilən video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Seçilə bilən element"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Düymə"</string>
</resources>
diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml
index 8867bb06..86fc1854 100644
--- a/java/res/values-b+sr+Latn/strings.xml
+++ b/java/res/values-b+sr+Latn/strings.xml
@@ -57,17 +57,19 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ još # fajl}one{+ još # fajl}few{+ još # fajla}other{+ još # fajlova}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Deli se tekst"</string>
<string name="sharing_link" msgid="2307694372813942916">"Deli se link"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deli se slika}one{Deli se # slika}few{Dele se # slike}other{Deli se # slika}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deljenje slike}one{Deljenje # slike}few{Deljenje # slike}other{Deljenje # slika}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deli se video}one{Deli se # video}few{Dele se # video snimka}other{Deli se # videa}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deli se # fajl}one{Deli se # fajl}few{Dele se # fajla}other{Deli se # fajlova}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Izaberite stavke za deljenje"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deli se slika sa tekstom}one{Deli se # slika sa tekstom}few{Dele se # slike sa tekstom}other{Deli se # slika sa tekstom}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deli se slika sa linkom}one{Deli se # slika sa linkom}few{Dele se # slike sa linkom}other{Deli se # slika sa linkom}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deli se video sa tekstom}one{Deli se # video sa tekstom}few{Dele se # video snimka sa tekstom}other{Deli se # videa sa tekstom}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deli se video sa linkom}one{Deli se # video sa linkom}few{Dele se # video snimka sa linkom}other{Deli se # videa sa linkom}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deli se fajl sa tekstom}one{Deli se # fajl sa tekstom}few{Dele se # fajla sa tekstom}other{Deli se # fajlova sa tekstom}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deli se fajl sa linkom}one{Deli se # fajl sa linkom}few{Dele se # fajla sa linkom}other{Deli se # fajlova sa linkom}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deljeni album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string>
- <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo video}one{Samo video snimci}few{Samo video snimci}other{Samo video snimci}}"</string>
+ <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo video}one{Samo videi}few{Samo videi}other{Samo videi}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Sličica za pregled slike"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Sličica za pregled videa"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ova aplikacija nema dozvolu za snimanje, ali bi mogla da snima zvuk pomoću ovog USB uređaja."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Poslovno"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Lični prikaz"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz za posao"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokira IT administrator"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj ne može da se deli pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj ne može da se otvara pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj ne može da se deli pomoću ličnih aplikacija"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj ne može da se otvara pomoću ličnih aplikacija"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ovaj sadržaj ne može da se deli pomoću privatnih aplikacija"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ovaj sadržaj ne može da se otvori pomoću privatnih aplikacija"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo aktiviraj"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Bez privatnih aplikacija"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite da na ličnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite da na poslovnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični pregledač"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Uvrsti tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Izuzmi link"</string>
<string name="include_link" msgid="827855767220339802">"Uvrsti link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Zakačeno"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Slika koja može da se izabere"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video koji može da se izabere"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Stavka koja može da se izabere"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Dugme"</string>
</resources>
diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml
index 7f0a3939..97ca27d3 100644
--- a/java/res/values-be/strings.xml
+++ b/java/res/values-be/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Абагульванне відарыса}one{Абагульванне # відарыса}few{Абагульванне # відарысаў}many{Абагульванне # відарысаў}other{Абагульванне # відарыса}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Абагульванне відэа}one{Абагульванне # відэа}few{Абагульванне # відэа}many{Абагульванне # відэа}other{Абагульванне # відэа}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Абагульваецца # файл}one{Абагульваецца # файл}few{Абагульваюцца # файлы}many{Абагульваюцца # файлаў}other{Абагульваюцца # файла}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Выберыце элементы для абагульвання"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Абагульванне відарыса з тэкстам}one{Абагульванне # відарыса з тэкстам}few{Абагульванне # відарысаў з тэкстам}many{Абагульванне # відарысаў з тэкстам}other{Абагульванне # відарыса з тэкстам}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Абагульванне відарыса са спасылкай}one{Абагульванне # відарыса са спасылкай}few{Абагульванне # відарысаў са спасылкай}many{Абагульванне # відарысаў са спасылкай}other{Абагульванне # відарыса са спасылкай}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Абагульванне відэа з тэкстам}one{Абагульванне # відэа з тэкстам}few{Абагульванне # відэа з тэкстам}many{Абагульванне # відэа з тэкстам}other{Абагульванне # відэа з тэкстам}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Абагульванне відэа са спасылкай}one{Абагульванне # відэа са спасылкай}few{Абагульванне # відэа са спасылкай}many{Абагульванне # відэа са спасылкай}other{Абагульванне # відэа са спасылкай}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Абагульванне файла з тэкстам}one{Абагульванне # файла з тэкстам}few{Абагульванне # файлаў з тэкстам}many{Абагульванне # файлаў з тэкстам}other{Абагульванне # файла з тэкстам}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Абагульванне файла са спасылкай}one{Абагульванне # файла са спасылкай}few{Абагульванне # файлаў са спасылкай}many{Абагульванне # файлаў са спасылкай}other{Абагульванне # файла са спасылкай}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Ідзе абагульванне альбома"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Толькі відарыс}one{Толькі відарысы}few{Толькі відарысы}many{Толькі відарысы}other{Толькі відарысы}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Толькі відэа}one{Толькі відэа}few{Толькі відэа}many{Толькі відэа}other{Толькі відэа}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Толькі файл}one{Толькі файлы}few{Толькі файлы}many{Толькі файлы}other{Толькі файлы}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"У гэтай праграмы няма дазволу на запіс, аднак яна зможа запісваць аўдыя праз гэту USB-прыладу."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Асабісты"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Працоўны"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Прыватная прастора"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Прагляд асабістага змесціва"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Прагляд працоўнага змесціва"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Прыватная прастора"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблакіравана вашым ІТ-адміністратарам"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Не ўдалося абагуліць гэта змесціва з працоўнымі праграмамі"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Не ўдалося адкрыць гэта змесціва з дапамогай працоўных праграм"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Не ўдалося абагуліць гэта змесціва з асабістымі праграмамі"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Не ўдалося адкрыць гэта змесціва з дапамогай асабістых праграм"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Не ўдалося абагуліць гэта змесціва з прыватнымі праграмамі"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Не ўдалося адкрыць гэта змесціва з дапамогай прыватных праграм"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Працоўныя праграмы прыпынены"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Уключыць"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма працоўных праграм"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма асабістых праграм"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Даступных прыватных праграм няма"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем асабістага профілю?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем працоўнага профілю?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Скарыстаць асабісты браўзер"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Дадаць тэкст"</string>
<string name="exclude_link" msgid="1332778255031992228">"Выдаліць спасылку"</string>
<string name="include_link" msgid="827855767220339802">"Дадаць спасылку"</string>
+ <string name="pinned" msgid="7623664001331394139">"Замацавана"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Відарыс, які можна выбраць"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Відэа, якое можна выбраць"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Элемент, які можна выбраць"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Кнопка"</string>
</resources>
diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml
index 8ee68f9f..3cec0cdf 100644
--- a/java/res/values-bg/strings.xml
+++ b/java/res/values-bg/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # файл}other{+ # файла}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ още # файл}other{+ още # файла}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Текстът се споделя"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Връзката се споделя"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Споделяне на връзката"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Изображението се споделя}other{# изображения се споделят}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Видеоклипът се споделя}other{# видеоклипа се споделят}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# файл се споделя}other{# файла се споделят}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Изберете елементи за споделяне"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Споделяне на изображението чрез SMS съобщение}other{Споделяне на # изображения чрез SMS съобщение}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Споделяне на изображението чрез връзка}other{Споделяне на # изображения чрез връзка}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Споделяне на видеоклипа чрез SMS съобщение}other{Споделяне на # видеоклипа чрез SMS съобщение}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Споделяне на видеоклипа чрез връзка}other{Споделяне на # видеоклипа чрез връзка}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Споделяне на файла чрез SMS съобщение}other{Споделяне на # файла чрез SMS съобщение}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Споделяне на файла чрез връзка}other{Споделяне на # файла чрез връзка}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Споделяне на албума"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само изображение}other{Само изображения}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видеоклип}other{Само видеоклипове}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само файл}other{Само файлове}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложението няма разрешение за записване, но може да записва звук чрез това USB устройство."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Служебни"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Частно"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен изглед"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Служебен изглед"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Частен изглед"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано от системния ви администратор"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Това съдържание не може да се споделя със служебни приложения"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Това съдържание не може да се отваря със служебни приложения"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Това съдържание не може да се споделя с лични приложения"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Това съдържание не може да се отваря с лични приложения"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Това съдържание не може да се споделя с частни приложения"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Това съдържание не може да се отваря с частни приложения"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Служебните приложения са поставени на пауза"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Отмяна на паузата"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма подходящи служебни приложения"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма подходящи лични приложения"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Няма частни приложения"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в личния си потребителски профил?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в служебния си потребителски профил?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Използване на личния браузър"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Включване на текста"</string>
<string name="exclude_link" msgid="1332778255031992228">"Изключване на връзката"</string>
<string name="include_link" msgid="827855767220339802">"Включване на връзката"</string>
+ <string name="pinned" msgid="7623664001331394139">"Фиксирано"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Избираемо изображение"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Избираем видеоклип"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Избираем елемент"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Бутон"</string>
</resources>
diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml
index 213e9a1d..ea524006 100644
--- a/java/res/values-bn/strings.xml
+++ b/java/res/values-bn/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"এই দিয়ে ছবি তুলুন"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> দিয়ে ছবি তুলুন"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"ছবি তুলুন"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"আলাদা কোনো অ্যাপ্লিকেশান ব্যবহার করুন"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"আলাদা কোনও অ্যাপ ব্যবহার করুন"</string>
<string name="chooseActivity" msgid="6659724877523973446">"একটি অ্যাকশন বেছে নিন"</string>
<string name="noApplications" msgid="1139487441772284671">"কোনো অ্যাপ্লিকেশানই এই ক্রিয়া সঞ্চালন করতে পারবে না৷"</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"আপনি এই অ্যাপ্লিকেশানটি আপনার কর্মস্থলের প্রোফাইলের বাইরে ব্যবহার করছেন"</string>
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{আরও #টি ফাইল}one{আরও #টি ফাইল}other{আরও #টি ফাইল}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{আরও #টি ফাইল}one{আরও #টি ফাইল}other{আরও #টি ফাইল}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"টেক্সট শেয়ার করা হচ্ছে"</string>
- <string name="sharing_link" msgid="2307694372813942916">"শেয়ার করা লিঙ্ক"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"শেয়ার করার জন্য লিঙ্ক"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ছবি শেয়ার করা হচ্ছে}one{#টি ছবি শেয়ার করা হচ্ছে}other{#টি ছবি শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ভিডিও শেয়ার করা হচ্ছে}one{#টি ভিডিও শেয়ার করা হচ্ছে}other{#টি ভিডিও শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{#টি ফাইল শেয়ার করা হচ্ছে}one{#টি ফাইল শেয়ার করা হচ্ছে}other{#টি ফাইল শেয়ার করা হচ্ছে}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"শেয়ার করার জন্য আইটেম বেছে নিন"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{টেক্সট সহ ছবি শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ছবি শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ছবি শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{লিঙ্ক সহ ছবি শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ছবি শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ছবি শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{টেক্সট সহ ভিডিও শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ভিডিও শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ভিডিও শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিঙ্ক সহ ভিডিও শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{টেক্সট সহ ফাইল শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিঙ্ক সহ ফাইল শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"অ্যালবাম শেয়ার করা হচ্ছে"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{শুধু ছবি}one{শুধু ছবি}other{শুধু ছবি}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{শুধু ভিডিও}one{শুধু ভিডিও}other{শুধু ভিডিও}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{শুধু ফাইল}one{শুধু ফাইল}other{শুধু ফাইল}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই অ্যাপকে রেকর্ড করার অনুমতি দেওয়া হয়নি কিন্তু USB ডিভাইসের মাধ্যমে সেটি অডিও রেকর্ড করতে পারে।"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"অফিস"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"প্রাইভেট"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"অফিসের ভিউ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপনার আইটি অ্যাডমিন ব্লক করেছেন"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"অফিসের অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"অফিসের অ্যাপে এই খোলা যাবে না"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট খোলা যাবে না"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে শেয়ার করা যাবে না"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে খোলা যাবে না"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"অফিসের অ্যাপ পজ করা আছে"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ করুন"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"এর জন্য কোনও অফিস অ্যাপ নেই"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ব্যক্তিগত অ্যাপে দেখা যাবে না"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনও ব্যক্তিগত অ্যাপ নেই"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপনার ব্যক্তিগত প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপনার অফিস প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্রাউজার ব্যবহার করুন"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"টেক্সট যোগ করুন"</string>
<string name="exclude_link" msgid="1332778255031992228">"লিঙ্ক বাদ দিন"</string>
<string name="include_link" msgid="827855767220339802">"লিঙ্ক যোগ করুন"</string>
+ <string name="pinned" msgid="7623664001331394139">"পিন করা হয়েছে"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"বেছে নেওয়া যাবে এমন ছবি"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"বেছে নেওয়া যাবে এমন ভিডিও"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"বেছে নেওয়া যাবে এমন আইটেম"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"বোতাম"</string>
</resources>
diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml
index 0d4abdc1..ddf3119b 100644
--- a/java/res/values-bs/strings.xml
+++ b/java/res/values-bs/strings.xml
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{i još # fajl}one{i još # fajl}few{i još # fajla}other{i još # fajlova}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Dijeljenje teksta"</string>
<string name="sharing_link" msgid="2307694372813942916">"Dijeljenje linka"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeljenje slike}one{Dijeljenje # slike}few{Dijeljenje # slike}other{Dijeljenje # slika}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Podijelite sliku}one{Podijelite # sliku}few{Podijelite # slike}other{Podijelite # slika}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Dijeljenje videozapisa}one{Dijeljenje # videozapisa}few{Dijeljenje # videozapisa}other{Dijeljenje # videozapisa}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeljenje # fajla}one{Dijeljenje # fajla}few{Dijeljenje # fajla}other{Dijeljenje # fajlova}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Odaberite stavke za dijeljenje"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeljenje slike putem poruke}one{Dijeljenje # slike putem poruke}few{Dijeljenje # slike putem poruke}other{Dijeljenje # slika putem poruke}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Dijeljenje slike putem linka}one{Dijeljenje # slike putem linka}few{Dijeljenje # slike putem linka}other{Dijeljenje # slika putem linka}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Dijeljenje videozapisa putem poruke}one{Dijeljenje # videozapisa putem poruke}few{Dijeljenje # videozapisa putem poruke}other{Dijeljenje # videozapisa putem poruke}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeljenje videozapisa putem linka}one{Dijeljenje # videozapisa putem linka}few{Dijeljenje # videozapisa putem linka}other{Dijeljenje # videozapisa putem linka}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeljenje fajla putem poruke}one{Dijeljenje # fajla putem poruke}few{Dijeljenje # fajla putem poruke}other{Dijeljenje # fajlova putem poruke}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeljenje fajla putem linka}one{Dijeljenje # fajla putem linka}few{Dijeljenje # fajla putem linka}other{Dijeljenje # fajlova putem linka}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ovoj aplikaciji nije dato odobrenje za snimanje, ali može snimati zvuk putem ovog USB uređaja."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Prikaz ličnog sadržaja"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz poslovnog sadržaja"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatan prikaz"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao je vaš IT administrator"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj nije moguće dijeliti pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj nije moguće otvoriti pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj nije moguće dijeliti pomoću ličnih aplikacija"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj nije moguće otvoriti pomoću ličnih aplikacija"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Sadržaj se ne može dijeliti pomoću privatnih aplikacija"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Sadržaj se ne može otvoriti pomoću privatnih aplikacija"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo pokreni"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na ličnom profilu?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na radnom profilu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični preglednik"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Uključi tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Izuzmi link"</string>
<string name="include_link" msgid="827855767220339802">"Uključi link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Zakačeno"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Slika koju je moguće odabrati"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Videozapis koji je moguće odabrati"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Stavka koju je moguće odabrati"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Dugme"</string>
</resources>
diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml
index 5d396a4f..48d7138f 100644
--- a/java/res/values-ca/strings.xml
+++ b/java/res/values-ca/strings.xml
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{# fitxer més}many{# de fitxers més}other{# fitxers més}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"S\'està compartint text"</string>
<string name="sharing_link" msgid="2307694372813942916">"S\'està compartint un enllaç"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{S\'està compartint una imatge}many{S\'estan compartint # d\'imatges}other{S\'estan compartint # imatges}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Comparteix una imatge}many{Comparteix # d\'imatges}other{Comparteix # imatges}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{S\'està compartint un vídeo}many{S\'estan compartint # de vídeos}other{S\'estan compartint # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{S\'està compartint # fitxer}many{S\'estan compartint # de fitxers}other{S\'estan compartint # fitxers}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecciona els elements que vols compartir"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{S\'està compartint la imatge amb text}many{S\'estan compartint # d\'imatges amb text}other{S\'estan compartint # imatges amb text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{S\'està compartint la imatge amb un enllaç}many{S\'estan compartint # d\'imatges amb un enllaç}other{S\'estan compartint # imatges amb un enllaç}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{S\'està compartint el vídeo amb un enllaç}many{S\'estan compartint # de vídeos amb un enllaç}other{S\'estan compartint # vídeos amb un enllaç}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{S\'està compartint el vídeo amb un enllaç}many{S\'estan compartint # de vídeos amb un enllaç}other{S\'estan compartint # vídeos amb un enllaç}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{S\'està compartint el fitxer amb text}many{S\'estan compartint # de fitxers amb text}other{S\'estan compartint # fitxers amb text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{S\'està compartint el fitxer amb un enllaç}many{S\'estan compartint # de fitxers amb un enllaç}other{S\'estan compartint # fitxers amb un enllaç}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"S\'està compartint l\'àlbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Només imatge}many{Només imatges}other{Només imatges}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Només vídeo}many{Només vídeos}other{Només vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Només fitxer}many{Només fitxers}other{Només fitxers}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aquesta aplicació no té permís de gravació, però pot capturar àudio a través d\'aquest dispositiu USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Feina"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualització personal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualització de treball"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualització privada"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloquejat per l\'administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No es pot compartir aquest contingut amb aplicacions de treball"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No es pot obrir aquest contingut amb aplicacions de treball"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No es pot compartir aquest contingut amb aplicacions personals"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No es pot obrir aquest contingut amb aplicacions personals"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Aquest contingut no es pot compartir amb aplicacions privades"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Aquest contingut no es pot obrir amb aplicacions privades"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les aplicacions de treball estan en pausa"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactiva"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Cap aplicació de treball"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Cap aplicació personal"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Cap aplicació privada"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil personal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil de treball?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilitza el navegador personal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inclou text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclou l\'enllaç"</string>
<string name="include_link" msgid="827855767220339802">"Inclou l\'enllaç"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fixat"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imatge seleccionable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo seleccionable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Element seleccionable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botó"</string>
</resources>
diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml
index 06336793..151e2147 100644
--- a/java/res/values-cs/strings.xml
+++ b/java/res/values-cs/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sdílení obrázku}few{Sdílení # obrázků}many{Sdílení # obrázku}other{Sdílení # obrázků}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sdílení videa}few{Sdílení # videí}many{Sdílení # videa}other{Sdílení # videí}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sdílení # souboru}few{Sdílení # souborů}many{Sdílení # souboru}other{Sdílení # souborů}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Vyberte položky, které chcete sdílet"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sdílení obrázku s textem}few{Sdílení # obrázků s textem}many{Sdílení # obrázku s textem}other{Sdílení # obrázků s textem}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sdílení obrázku s odkazem}few{Sdílení # obrázků s odkazem}many{Sdílení # obrázku s odkazem}other{Sdílení # obrázků s odkazem}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sdílení videa s textem}few{Sdílení # videí s textem}many{Sdílení # videa s textem}other{Sdílení # videí s textem}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sdílení videa s odkazem}few{Sdílení # videí s odkazem}many{Sdílení # videa s odkazem}other{Sdílení # videí s odkazem}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sdílení souboru s textem}few{Sdílení # souborů s textem}many{Sdílení # souboru s textem}other{Sdílení # souborů s textem}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sdílení souboru s odkazem}few{Sdílení # souborů s odkazem}many{Sdílení # souboru s odkazem}other{Sdílení # souborů s odkazem}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Sdílení alba"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Pouze obrázek}few{Pouze obrázky}many{Pouze obrázky}other{Pouze obrázky}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Pouze video}few{Pouze videa}many{Pouze videa}other{Pouze videa}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Pouze soubor}few{Pouze soubory}many{Pouze soubory}other{Pouze soubory}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tato aplikace nemá oprávnění k nahrávání, ale může zaznamenávat zvuk prostřednictvím tohoto zařízení USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Osobní"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Pracovní"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Soukromé"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobní zobrazení"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovní zobrazení"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Soukromé zobrazení"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokováno administrátorem IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah nelze sdílet pomocí pracovních aplikací"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah nelze otevřít pomocí pracovních aplikací"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah nelze sdílet pomocí osobních aplikací"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah nelze otevřít pomocí osobních aplikací"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah nelze sdílet se soukromými aplikacemi"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah nelze otevřít pomocí soukromých aplikací"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovní aplikace jsou pozastaveny"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušit pozastavení"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žádné pracovní aplikace"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žádné osobní aplikace"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žádné soukromé aplikace"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v osobním profilu?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v pracovním profilu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použít osobní prohlížeč"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Zahrnout text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Vyloučit odkaz"</string>
<string name="include_link" msgid="827855767220339802">"Zahrnout odkaz"</string>
+ <string name="pinned" msgid="7623664001331394139">"Připnuto"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Vybratelný obrázek"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vybratelné video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Vybratelná položka"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Tlačítko"</string>
</resources>
diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml
index 57f81416..e9d952fe 100644
--- a/java/res/values-da/strings.xml
+++ b/java/res/values-da/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deler billede}one{Deler # billede}other{Deler # billeder}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deler video}one{Deler # video}other{Deler # videoer}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deler # fil}one{Deler # fil}other{Deler # filer}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Vælg elementer til deling"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deler billede med tekst}one{Deler # billede med tekst}other{Deler # billeder med tekst}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deler billede med et link}one{Deler # billede med et link}other{Deler # billeder med et link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deler video med tekst}one{Deler # video med tekst}other{Deler # videoer med tekst}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler video med et link}one{Deler # video med et link}other{Deler # videoer med et link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler fil med tekst}one{Deler # fil med tekst}other{Deler # filer med tekst}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler fil med et link}one{Deler # fil med et link}other{Deler # filer med et link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deling af albummet"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Kun billedet}one{Kun billedet}other{Kun billeder}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Kun video}one{Kun video}other{Kun videoer}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Kun filen}one{Kun filen}other{Kun filer}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne app har ikke fået tilladelse til at optage, men optager muligvis lyd via denne USB-enhed."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Arbejde"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visningen Personligt"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visningen Arbejde"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visningen Privat"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeret af din it-administrator"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette indhold kan ikke deles med arbejdsapps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette indhold kan ikke åbnes med arbejdsapps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette indhold kan ikke deles med personlige apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette indhold kan ikke åbnes med personlige apps"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette indhold kan ikke deles med private apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette indhold kan ikke åbnes med private apps"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Dine arbejdsapps er sat på pause"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Genoptag"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Der er ingen arbejdsapps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Der er ingen personlige apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din personlige profil?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din arbejdsprofil?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Brug personlig browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inkluder tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Ekskluder link"</string>
<string name="include_link" msgid="827855767220339802">"Inkluder link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fastgjort"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Billede, der kan vælges"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video, der kan vælges"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Element, der kan vælges"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Knap"</string>
</resources>
diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml
index f5de3756..911dd273 100644
--- a/java/res/values-de/strings.xml
+++ b/java/res/values-de/strings.xml
@@ -46,7 +46,7 @@
<string name="chooseActivity" msgid="6659724877523973446">"Aktion auswählen"</string>
<string name="noApplications" msgid="1139487441772284671">"Diese Aktion kann von keiner App ausgeführt werden."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"Du verwendest diese App außerhalb deines Arbeitsprofils"</string>
- <string name="forward_intent_to_work" msgid="2906094223089139419">"Du verwendest diese App in deinem Arbeitsprofil."</string>
+ <string name="forward_intent_to_work" msgid="2906094223089139419">"Du verwendest diese App in deinem Arbeitsprofil"</string>
<string name="activity_resolver_use_always" msgid="8674194687637555245">"Immer"</string>
<string name="activity_resolver_use_once" msgid="594173435998892989">"Nur diesmal"</string>
<string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"<xliff:g id="APP">%1$s</xliff:g> unterstützt das Arbeitsprofil nicht"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Bild wird geteilt}other{# Bilder werden geteilt}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video wird geteilt}other{# Videos werden geteilt}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# Datei wird freigegeben}other{# Dateien werden freigegeben}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Elemente zum Teilen auswählen"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Bild wird mit Text geteilt}other{# Bilder werden mit Text geteilt}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bild wird per Link geteilt}other{# Bilder werden per Link geteilt}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Video wird per SMS geteilt}other{# Videos werden per SMS geteilt}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video wird per Link geteilt}other{# Videos werden per Link geteilt}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Datei wird per SMS geteilt}other{# Dateien werden per SMS geteilt}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Datei wird per Link geteilt}other{# Dateien werden per Link geteilt}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Album teilen"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Nur Bild}other{Nur Bilder}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Nur Video}other{Nur Videos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Nur Datei}other{Nur Dateien}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Diese App hat noch keine Berechtigung zum Aufnehmen erhalten, könnte aber Audioaufnahmen über dieses USB-Gerät machen."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Geschäftlich"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Vertraulich"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Private Ansicht"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Geschäftliche Ansicht"</string>
- <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Von deinem IT-Administrator blockiert"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private Ansicht"</string>
+ <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Vom IT‑Administrator blockiert"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Diese Art von Inhalt kann nicht über geschäftliche Apps geteilt werden"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Diese Art von Inhalt kann nicht mit geschäftlichen Apps geöffnet werden"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Diese Art von Inhalt kann nicht über private Apps geteilt werden"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Diese Art von Inhalt kann nicht mit privaten Apps geöffnet werden"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Diese Art von Inhalt kann nicht über interne Apps geteilt werden"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Diese Art von Inhalt kann nicht mit internen Apps geöffnet werden"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Geschäftliche Apps sind pausiert"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Nicht mehr pausieren"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Keine geschäftlichen Apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Keine privaten Apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Keine internen Apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> in deinem privaten Profil öffnen?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> in deinem Arbeitsprofil öffnen?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Privaten Browser verwenden"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Text einschließen"</string>
<string name="exclude_link" msgid="1332778255031992228">"Link ausschließen"</string>
<string name="include_link" msgid="827855767220339802">"Link einschließen"</string>
+ <string name="pinned" msgid="7623664001331394139">"Angepinnt"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Auswählbares Bild"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Auswählbares Video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Auswählbares Element"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Schaltfläche"</string>
</resources>
diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml
index b0c4abf5..319a3e2c 100644
--- a/java/res/values-el/strings.xml
+++ b/java/res/values-el/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Κοινοποίηση εικόνας}other{Κοινοποίηση # εικόνων}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Κοινοποίηση βίντεο}other{Κοινοποίηση # βίντεο}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Κοινή χρήση # αρχείου}other{Κοινή χρήση # αρχείων}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Επιλογή στοιχείων για κοινή χρήση"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Κοινοποίηση εικόνας με κείμενο}other{Κοινοποίηση # εικόνων με κείμενο}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Κοινοποίηση εικόνας με σύνδεσμο}other{Κοινοποίηση # εικόνων με σύνδεσμο}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Κοινοποίηση βίντεο με κείμενο}other{Κοινοποίηση # βίντεο με κείμενο}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Κοινοποίηση βίντεο με σύνδεσμο}other{Κοινοποίηση # βίντεο με σύνδεσμο}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Κοινοποίηση αρχείου με κείμενο}other{Κοινοποίηση # αρχείων με κείμενο}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Κοινοποίηση αρχείου με σύνδεσμο}other{Κοινοποίηση # αρχείων με σύνδεσμο}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Κοινοποίηση λευκώματος"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Μόνο εικόνα}other{Μόνο εικόνες}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Μόνο βίντεο}other{Μόνο βίντεο}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Μόνο αρχείο}other{Μόνο αρχεία}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Δεν έχει εκχωρηθεί άδεια εγγραφής σε αυτή την εφαρμογή, αλλά μέσω αυτής της συσκευής USB θα μπορεί να εγγράφει ήχο."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Προσωπικό"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Εργασία"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Ιδιωτικός"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Προσωπική προβολή"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Προβολή εργασίας"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ιδιωτική προβολή"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Αποκλείστηκε από τον διαχειριστή IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με εφαρμογές εργασιών"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με εφαρμογές εργασιών"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με προσωπικές εφαρμογές"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με προσωπικές εφαρμογές"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου σε ιδιωτικές εφαρμογές"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με ιδιωτικές εφαρμογές"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Οι εφαρμογές εργασιών τέθηκαν σε παύση"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Αναίρεση παύσης"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Δεν υπάρχουν εφαρμογές εργασιών"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Δεν υπάρχουν προσωπικές εφαρμογές"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Καμία ιδιωτική εφαρμογή"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προσωπικό σας προφίλ;"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προφίλ σας εργασίας;"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Χρήση προσωπικού προγράμματος περιήγησης"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Συμπερίληψη κειμένου"</string>
<string name="exclude_link" msgid="1332778255031992228">"Εξαίρεση συνδέσμου"</string>
<string name="include_link" msgid="827855767220339802">"Συμπερίληψη συνδέσμου"</string>
+ <string name="pinned" msgid="7623664001331394139">"Καρφιτσωμένο"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Εικόνα με δυνατότητα επιλογής"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Βίντεο με δυνατότητα επιλογής"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Στοιχείο με δυνατότητα επιλογής"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Κουμπί"</string>
</resources>
diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml
index c42a5531..4d16a6f4 100644
--- a/java/res/values-en-rAU/strings.xml
+++ b/java/res/values-en-rAU/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Include text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string>
<string name="include_link" msgid="827855767220339802">"Include link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pinned"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Selectable image"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Selectable video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Selectable item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Button"</string>
</resources>
diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml
index c42a5531..9f6d20c3 100644
--- a/java/res/values-en-rCA/strings.xml
+++ b/java/res/values-en-rCA/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can’t be shared with private apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can’t be opened with private apps"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Include text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string>
<string name="include_link" msgid="827855767220339802">"Include link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pinned"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Selectable image"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Selectable video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Selectable item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Button"</string>
</resources>
diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml
index c42a5531..4d16a6f4 100644
--- a/java/res/values-en-rGB/strings.xml
+++ b/java/res/values-en-rGB/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Include text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string>
<string name="include_link" msgid="827855767220339802">"Include link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pinned"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Selectable image"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Selectable video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Selectable item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Button"</string>
</resources>
diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml
index c42a5531..4d16a6f4 100644
--- a/java/res/values-en-rIN/strings.xml
+++ b/java/res/values-en-rIN/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Include text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string>
<string name="include_link" msgid="827855767220339802">"Include link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pinned"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Selectable image"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Selectable video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Selectable item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Button"</string>
</resources>
diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml
index 95a8922d..4fc18b62 100644
--- a/java/res/values-en-rXC/strings.xml
+++ b/java/res/values-en-rXC/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‎‏‎‎‎‏‏‏‎‎‎‎‎‏‏‏‎‎‎‎‎‎‎‏‏‏‏‎‏‏‏‏‏‎‎‏‎‏‏‏‎‏‎‎‎‏‎‏‏‎‏‎‎‎‏‎‏‎‏‏‎‎Sharing image‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‎‏‎‎‎‏‏‏‎‎‎‎‎‏‏‏‎‎‎‎‎‎‎‏‏‏‏‎‏‏‏‏‏‎‎‏‎‏‏‏‎‏‎‎‎‏‎‏‏‎‏‎‎‎‏‎‏‎‏‏‎‎Sharing # images‎‏‎‎‏‎}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‎‎‎‏‏‎‏‏‏‎‏‎‏‏‏‎‎‎‎‎‎‏‏‎‎‏‏‏‏‏‎‏‏‎‏‎‏‎‏‏‏‏‏‏‎‏‎‏‏‎‎‎‏‏‏‏‏‎‏‎‎Sharing video‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‎‎‎‏‏‎‏‏‏‎‏‎‏‏‏‎‎‎‎‎‎‏‏‎‎‏‏‏‏‏‎‏‏‎‏‎‏‎‏‏‏‏‏‏‎‏‎‏‏‎‎‎‏‏‏‏‏‎‏‎‎Sharing # videos‎‏‎‎‏‎}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‎‏‏‎‏‏‎‏‎‎‎‎‎‎‎‎‎‏‏‏‎‎‎‏‎‏‏‎‎‎‎‎‎‏‏‎‎‎‎‏‏‏‏‎‏‎‎‏‏‎‎‎‎‏‎‏‏‏‎Sharing # file‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‎‏‏‎‏‏‎‏‎‎‎‎‎‎‎‎‎‏‏‏‎‎‎‏‎‏‏‎‎‎‎‎‎‏‏‎‎‎‎‏‏‏‏‎‏‎‎‏‏‎‎‎‎‏‎‏‏‏‎Sharing # files‎‏‎‎‏‎}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‎‏‏‏‎‎‎‏‏‏‏‎‏‎‏‎‏‎‏‏‎‏‏‎‏‏‎‎‎‎‏‎‏‎‏‏‎‏‎‎‎‏‎‎‎‎‎‎‎‏‏‎‎‏‏‏‏‎‎‏‏‎Select items to share‎‏‎‎‏‎"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‏‏‎‎‏‏‏‏‏‎‏‎‏‎‏‏‏‏‎‎‎‏‎‎‏‎‏‎‏‏‎‏‎‏‎‎‏‎‏‎‎‎‏‎‎‎‏‏‎‏‎‏‏‏‎‎‎‎‏‎‎Sharing image with text‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‏‏‎‎‏‏‏‏‏‎‏‎‏‎‏‏‏‏‎‎‎‏‎‎‏‎‏‎‏‏‎‏‎‏‎‎‏‎‏‎‎‎‏‎‎‎‏‏‎‏‎‏‏‏‎‎‎‎‏‎‎Sharing # images with text‎‏‎‎‏‎}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‏‎‏‏‏‎‎‏‏‏‏‏‎‎‏‏‎‎‎‏‏‎‏‏‏‎‏‎‏‏‎‏‎‎‎‎‎‎‏‎‎‏‎‎‏‎‏‏‏‏‏‏‎‏‏‎‎‏‎‏‎Sharing image with link‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‏‎‏‏‏‎‎‏‏‏‏‏‎‎‏‏‎‎‎‏‏‎‏‏‏‎‏‎‏‏‎‏‎‎‎‎‎‎‏‎‎‏‎‎‏‎‏‏‏‏‏‏‎‏‏‎‎‏‎‏‎Sharing # images with link‎‏‎‎‏‎}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‎‎‏‏‏‎‏‏‏‏‎‎‏‏‏‎‏‎‎‏‎‎‎‏‎‏‎‎‏‎‏‎‏‎‏‏‎‎‏‏‎‎‏‏‏‏‎‏‏‏‎‎‎‎‎‎‎‏‎‎Sharing video with text‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‎‎‏‏‏‎‏‏‏‏‎‎‏‏‏‎‏‎‎‏‎‎‎‏‎‏‎‎‏‎‏‎‏‎‏‏‎‎‏‏‎‎‏‏‏‏‎‏‏‏‎‎‎‎‎‎‎‏‎‎Sharing # videos with text‎‏‎‎‏‎}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‏‎‎‎‏‎‎‏‎‏‏‎‎‎‎‎‎‎‎‎‏‏‎‏‎‏‏‎‎‎‎‏‏‎‎‏‏‏‏‏‏‎‎‏‏‎‎‎‏‎‏‎‎‎‎‏‎‎‎‏‎Sharing video with link‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‏‎‎‎‏‎‎‏‎‏‏‎‎‎‎‎‎‎‎‎‏‏‎‏‎‏‏‎‎‎‎‏‏‎‎‏‏‏‏‏‏‎‎‏‏‎‎‎‏‎‏‎‎‎‎‏‎‎‎‏‎Sharing # videos with link‎‏‎‎‏‎}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‎‏‎‏‏‎‏‏‏‏‎‏‏‎‎‏‏‎‏‏‏‏‏‏‏‎‎‎‏‎‎‏‎‏‏‎‎‏‎‎‏‏‏‏‎‏‏‎‏‏‎‏‏‏‏‎‎‎‎‎‎Sharing file with text‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‎‏‎‏‏‎‏‏‏‏‎‏‏‎‎‏‏‎‏‏‏‏‏‏‏‎‎‎‏‎‎‏‎‏‏‎‎‏‎‎‏‏‏‏‎‏‏‎‏‏‎‏‏‏‏‎‎‎‎‎‎Sharing # files with text‎‏‎‎‏‎}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‎‎‏‏‏‏‏‏‏‏‏‏‏‏‎‏‏‎‏‎‏‏‏‎‏‎‎‏‏‏‏‎‎‏‏‏‏‎‏‏‎‏‎‎‎‏‎‏‏‎‎‎‏‏‏‎‎‏‏‏‎Sharing file with link‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‎‎‏‏‏‏‏‏‏‏‏‏‏‏‎‏‏‎‏‎‏‏‏‎‏‎‎‏‏‏‏‎‎‏‏‏‏‎‏‏‎‏‎‎‎‏‎‏‏‎‎‎‏‏‏‎‎‏‏‏‎Sharing # files with link‎‏‎‎‏‎}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‎‏‎‏‎‏‎‏‎‏‎‎‏‎‎‏‏‎‏‎‏‎‏‎‏‏‏‎‎‎‏‎‏‏‎‎‏‏‏‎‎‏‎‎‎‎‎‎‎‏‏‏‎‏‏‏‏‎‎‎‏‎Sharing album‎‏‎‎‏‎"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‏‎‏‏‏‎‏‏‏‎‏‎‎‏‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‎‏‎‎‏‏‏‎‎‎‎‏‎‎‎‎‎‏‏‎‏‏‎‏‏‏‎‎Image only‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‏‎‏‏‏‎‏‏‏‎‏‎‎‏‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‎‏‎‎‏‏‏‎‎‎‎‏‎‎‎‎‎‏‏‎‏‏‎‏‏‏‎‎Images only‎‏‎‎‏‎}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‎‏‏‎‏‎‎‎‎‎‏‎‎‏‎‎‏‏‎‎‏‎‏‎‏‏‎‎‏‏‏‏‎‏‏‏‎‏‏‎‎‏‏‏‎‏‏‏‎‎‎‏‎‏‎‎‏‏‏‏‎‎Video only‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‎‏‏‎‏‎‎‎‎‎‏‎‎‏‎‎‏‏‎‎‏‎‏‎‏‏‎‎‏‏‏‏‎‏‏‏‎‏‏‎‎‏‏‏‎‏‏‏‎‎‎‏‎‏‎‎‏‏‏‏‎‎Videos only‎‏‎‎‏‎}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‏‎‏‏‏‎‏‎‎‏‎‎‏‏‏‎‏‏‏‏‏‎‏‏‏‎‎‏‏‎‎‎‏‎‎‎‎‏‎‏‎‎‎‎‏‎‏‎‎‏‏‎‎‎‏‎‎‎‎‎‎File only‎‏‎‎‏‎}other{‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‏‎‏‏‏‎‏‎‎‏‎‎‏‏‏‎‏‏‏‏‏‎‏‏‏‎‎‏‏‎‎‎‏‎‎‎‎‏‎‏‎‎‎‎‏‎‏‎‎‏‏‎‎‎‏‎‎‎‎‎‎Files only‎‏‎‎‏‎}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‎‏‏‎‎‎‎‏‎‏‎‏‏‏‏‏‏‏‏‏‎‏‏‎‏‏‎‏‎‎‎‏‏‏‎‏‎‏‏‎‏‎‎‎‏‎‏‏‎‎‏‏‎‎‏‎‏‎‎‎This app has not been granted record permission but could capture audio through this USB device.‎‏‎‎‏‎"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‏‏‎‎‏‎‏‎‏‎‎‏‏‏‏‏‎‎‎‎‏‎‎‏‎‏‎‏‎‎‏‎‎‏‎‎‎‏‎‏‏‎‎‏‏‏‎‎‏‏‎‎‏‏‎‏‎‏‎Personal‎‏‎‎‏‎"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‎‎‎‏‏‏‎‎‏‏‎‎‎‏‎‎‏‎‏‏‎‎‏‏‏‎‏‎‎‏‏‎‏‏‏‏‎‏‎‏‎‎‎‏‎‎‎‏‏‏‏‏‎‎‎‏‏‏‎‎‎Work‎‏‎‎‏‎"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‎‎‏‏‎‏‏‏‎‎‏‏‏‏‎‏‏‏‎‎‎‎‎‎‎‎‏‏‏‎‎‏‏‎‎‎‎‏‎‎‏‎‏‎‏‎‏‏‎‎‏‏‎‎‏‏‎‏‎‏‎Private‎‏‎‎‏‎"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‏‏‎‎‎‎‎‎‎‎‎‏‏‎‎‎‎‏‎‎‎‏‏‎‎‏‎‎‏‏‎‏‏‏‏‏‏‎‏‏‏‏‏‎‎‏‎‏‏‎‏‏‎‏‎‏‏‏‎‎Personal view‎‏‎‎‏‎"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‏‎‎‏‎‎‏‏‏‎‎‎‎‎‏‏‏‏‏‎‏‎‏‎‎‎‎‏‏‎‏‎‎‎‎‎‏‏‎‏‏‏‏‎‎‎‏‎‎‏‎‏‎‏‏‏‎‎‎‎‎Work view‎‏‎‎‏‎"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‏‎‎‏‏‏‏‏‏‎‎‏‏‏‎‎‎‏‏‎‎‏‏‎‎‏‎‏‏‏‎‏‏‎‏‏‎‎‏‏‎‎‎‏‎‎‎Private view‎‏‎‎‏‎"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‎‎‎‎‏‏‎‎‏‎‎‎‎‏‏‏‏‎‏‎‎‏‏‎‎‎‎‎‎‏‏‎‏‏‏‎‏‏‏‎‎‏‎‎‏‎‏‏‏‏‎‎‎‏‎‎‎‎‎‏‎Blocked by your IT admin‎‏‎‎‏‎"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‎‏‎‎‏‎‏‏‎‏‎‎‏‏‎‏‎‏‏‎‎‎‏‏‏‎‏‏‎‏‎‎‎‎‏‎‎‏‏‏‎‎‏‏‎‎‎‏‎‎‎‎‎‎‏‎‏‎‎‏‏‎This content can’t be shared with work apps‎‏‎‎‏‎"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‏‎‎‎‏‎‎‏‏‎‏‏‏‏‏‎‏‎‎‎‎‎‎‏‎‎‎‏‏‏‏‏‎‎‏‏‎‎‎‏‏‎‏‎‏‏‎‎‏‎‎‏‏‎‏‏‎‏‎‎This content can’t be opened with work apps‎‏‎‎‏‎"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‏‎‎‎‏‏‏‎‏‎‏‎‎‎‏‎‎‎‏‎‎‏‏‏‏‏‏‏‎‎‎‎‏‎‏‏‎‎‎‎‏‎‏‏‏‏‎‎‏‎‎‎‏‏‏‏‏‎‎‏‎This content can’t be shared with personal apps‎‏‎‎‏‎"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‎‏‏‎‎‎‏‎‏‏‎‎‏‎‏‏‏‎‏‏‎‎‎‏‏‏‎‎‎‏‎‎‎‎‏‎‏‎‏‏‎‏‏‎‎‏‎‎‏‎‏‎‏‏‎‎‎‎‏‎‎This content can’t be opened with personal apps‎‏‎‎‏‎"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‏‎‎‎‏‎‏‏‏‎‏‎‏‏‎‏‏‏‏‎‎‏‎‎‎‏‏‎‏‎‎‏‏‎‏‏‎‎‏‏‎‏‎‎‎‏‏‏‎‎‎‎‏‏‎‎‏‎‎‏‎This content can’t be shared with private apps‎‏‎‎‏‎"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‎‎‏‎‏‏‏‏‏‎‎‎‎‏‎‎‏‎‏‎‎‎‎‎‏‏‏‏‎‏‎‏‎‎‎‎‎‎‎‎‎‎‏‎‏‎‏‎‎‎‏‎‏‏‎‏‎‏‏‎‎This content can’t be opened with private apps‎‏‎‎‏‎"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‎‎‏‎‏‎‏‏‏‏‏‎‎‏‏‏‏‎‏‏‏‏‎‎‏‎‏‏‎‎‏‏‎‏‎‎‎‎‏‎‏‎‎‎‏‎‏‏‏‎‏‏‎‏‎‎‎‏‎‎‎Work apps are paused‎‏‎‎‏‎"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‏‎‎‎‎‏‏‏‎‎‎‏‏‎‎‏‏‏‏‏‎‏‏‎‏‏‏‏‏‏‎‎‎‏‏‎‏‏‎‎‎‎‎‎‎‎‎‏‎‎‎‏‎‏‎‏‎‏‏‏‎Unpause‎‏‎‎‏‎"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‎‏‎‏‎‏‎‎‏‏‎‏‎‏‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‎‎‎‏‎‏‏‎‎‏‏‎‎‏‎‏‏‎‏‎‏‎‎‎‎‎‎‎‎‏‏‏‏‎No work apps‎‏‎‎‏‎"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‎‏‎‏‏‎‏‎‏‎‏‏‏‎‎‏‎‎‏‏‏‏‏‎‎‏‏‏‎‎‏‏‎‏‎‏‏‎‎‏‏‎‎‏‎‎‏‎‏‏‏‏‏‎‎‎‏‏‏‏‎No personal apps‎‏‎‎‏‎"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‎‎‏‏‏‎‎‏‎‏‏‎‎‏‎‏‏‏‎‏‎‏‎‎‎‎‎‏‎‏‎‏‎‏‎‏‎‏‎‏‏‏‏‎‏‎‏‎‎‏‏‎‏‏‏‎‎‎‎‎No private apps‎‏‎‎‏‎"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‏‎‏‎‎‏‎‎‎‏‎‎‏‎‏‏‏‏‎‏‎‎‎‎‎‎‎‏‏‏‏‏‎‏‎‏‏‏‎‎‏‎‏‏‎‏‏‎‎‎‎‎‎‏‏‏‏‏‏‏‏‎Open ‎‏‎‎‏‏‎<xliff:g id="APP">%s</xliff:g>‎‏‎‎‏‏‏‎ in your personal profile?‎‏‎‎‏‎"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‏‏‎‏‏‎‏‎‎‎‏‏‏‏‏‏‎‏‎‎‎‎‎‏‏‏‏‎‏‎‏‏‎‏‏‏‏‎‏‎‏‎‎‏‏‏‏‏‎‏‎‏‏‏‎‏‏‎‏‎‏‎Open ‎‏‎‎‏‏‎<xliff:g id="APP">%s</xliff:g>‎‏‎‎‏‏‏‎ in your work profile?‎‏‎‎‏‎"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‏‏‏‏‎‏‎‏‎‎‏‎‎‎‎‎‏‏‏‎‏‎‎‏‏‎‎‏‏‎‎‏‏‏‏‎‎‏‎‏‏‏‎‏‎‎‏‏‏‏‏‏‏‎‏‏‎‎‎Use personal browser‎‏‎‎‏‎"</string>
@@ -95,4 +102,8 @@
<string name="include_text" msgid="642280283268536140">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‎‏‎‎‎‏‏‏‎‏‎‎‏‏‏‎‏‎‏‏‎‏‎‎‎‎‎‏‏‏‏‎‎‎‎‏‎‎‏‏‏‎‏‎‏‎‏‎‏‎‏‏‏‎‏‎‎‏‏‎‎‎Include text‎‏‎‎‏‎"</string>
<string name="exclude_link" msgid="1332778255031992228">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‏‎‎‏‏‏‏‏‏‎‏‏‏‏‏‎‏‎‏‏‎‎‎‎‏‎‏‏‏‏‎‏‏‏‏‎‎‎‏‎‏‏‏‏‏‏‏‏‏‏‏‎‏‎‎‏‎‎‎Exclude link‎‏‎‎‏‎"</string>
<string name="include_link" msgid="827855767220339802">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‎‏‎‏‏‎‏‏‏‏‏‎‏‎‎‏‎‎‎‏‎‎‏‏‎‏‏‏‏‎‎‏‎‎‎‏‎‎‏‏‎‏‏‎‏‎‏‎‎‎‏‎‎‎‏‎‏‏‎‏‎‎Include link‎‏‎‎‏‎"</string>
+ <string name="pinned" msgid="7623664001331394139">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‏‎‎‏‏‏‎‎‏‏‎‎‏‎‏‏‎‎‏‎‎‎‎‎‎‏‎‎‎‏‏‎‏‏‏‎‎‏‎‎‏‎‏‎‎‏‏‏‎‏‏‎‎‏‎‏‏‎‏‏‎Pinned‎‏‎‎‏‎"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‎‏‎‏‏‏‏‎‏‎‎‏‎‏‏‏‏‎‏‏‏‏‏‏‏‎‏‎‏‏‎‏‏‎‏‏‎‎‏‎‎‎‏‎‏‎‏‎‎‏‏‎‏‎‎‏‏‏‏‏‏‎Selectable image‎‏‎‎‏‎"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‎‏‏‎‎‎‏‏‎‏‎‎‏‏‎‎‎‏‏‏‎‏‎‏‏‎‏‏‎‎‎‏‏‎‏‎‎‏‎‏‎‎‏‏‎‎‎‏‎‎‎‎‎‎‏‏‏‎‏‏‎‏‎‎Selectable video‎‏‎‎‏‎"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‏‏‏‎‏‎‎‎‏‏‏‎‎‎‎‎‏‏‏‏‏‏‏‏‎‎‏‏‏‏‏‎‎‎‎‎‏‏‏‏‏‎‏‏‎‏‎‎‎‎‎‎‏‎‏‏‏‏‏‎‎‎‎‎‎Selectable item‎‏‎‎‏‎"</string>
</resources>
diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml
index 7bb36dc1..923e9d36 100644
--- a/java/res/values-es-rUS/strings.xml
+++ b/java/res/values-es-rUS/strings.xml
@@ -59,13 +59,15 @@
<string name="sharing_link" msgid="2307694372813942916">"Compartir vínculo"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartir la imagen}many{Compartir # de imágenes}other{Compartir # imágenes}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartiendo video}many{Compartiendo # de videos}other{Compartiendo # videos}}"</string>
- <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Se compartirá # archivo}many{Se compartirán # de archivos}other{Se compartirán # archivos}}"</string>
+ <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartiendo # archivo}many{Compartiendo # de archivos}other{Compartiendo # archivos}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecciona los elementos para compartir"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartir imagen con texto}many{Compartir # de imágenes con texto}other{Compartir # imágenes con texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartir imagen con vínculo}many{Compartir # de imágenes con vínculo}other{Compartir # imágenes con vínculo}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartir video con texto}many{Compartir # de videos con texto}other{Compartir # videos con texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartir video con vínculo}many{Compartir # de videos con vínculo}other{Compartir # videos con vínculo}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartir archivo con texto}many{Compartir # de archivos con texto}other{Compartir # archivos con texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartir archivo con vínculo}many{Compartir # de archivos con vínculo}other{Compartir # archivos con vínculo}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Se comparte este álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo video}many{Solo videos}other{Solo videos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aunque no se le otorgó permiso de grabación a esta app, puede capturar audio con este dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista personal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabajo"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No se pueden usar apps de trabajo para compartir este contenido"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No se puede abrir este contenido con apps de trabajo"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No se pueden usar apps personales para compartir este contenido"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No se puede abrir este contenido con apps personales"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"No se puede compartir este contenido con apps privadas"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"No se puede abrir este contenido con apps privadas"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Se pausaron las apps de trabajo"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reanudar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"El contenido no es compatible con apps de trabajo"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"El contenido no es compatible con apps personales"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay apps privadas"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar un navegador personal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluir texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluir vínculo"</string>
<string name="include_link" msgid="827855767220339802">"Incluir vínculo"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fijado"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagen seleccionable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video seleccionable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Elemento seleccionable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botón"</string>
</resources>
diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml
index 22afe9e6..7cb07c61 100644
--- a/java/res/values-es/strings.xml
+++ b/java/res/values-es/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Editar"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # archivo}many{+ # archivos}other{+ # archivos}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{y # archivo más}many{y # archivos más}other{y # archivos más}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Compartiendo texto"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Compartir texto"</string>
<string name="sharing_link" msgid="2307694372813942916">"Compartiendo enlace"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartiendo imagen}many{Compartiendo # imágenes}other{Compartiendo # imágenes}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartiendo vídeo}many{Compartiendo # vídeos}other{Compartiendo # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartiendo # archivo}many{Compartiendo # archivos}other{Compartiendo # archivos}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecciona los elementos que quieres compartir"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartiendo imagen con texto}many{Compartiendo # imágenes con texto}other{Compartiendo # imágenes con texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartiendo imagen con enlace}many{Compartiendo # imágenes con enlace}other{Compartiendo # imágenes con enlace}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartiendo vídeo con texto}many{Compartiendo # vídeos con texto}other{Compartiendo # vídeos con texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartiendo vídeo con enlace}many{Compartiendo # vídeos con enlace}other{Compartiendo # vídeos con enlace}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartiendo archivo con mensaje de texto}many{Compartiendo # archivos con mensaje de texto}other{Compartiendo # archivos con mensaje de texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartiendo archivo con enlace}many{Compartiendo # archivos con enlace}other{Compartiendo # archivos con enlace}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Compartiendo álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo vídeo}many{Solo vídeos}other{Solo vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación no tiene permiso para grabar, pero podría capturar audio con este dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ver contenido personal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ver contenido de trabajo"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contenido no se puede compartir con aplicaciones de trabajo"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contenido no se puede abrir con aplicaciones de trabajo"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contenido no se puede compartir con aplicaciones personales"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contenido no se puede abrir con aplicaciones personales"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contenido no se puede compartir con aplicaciones privadas"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contenido no se puede abrir con aplicaciones privadas"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Las aplicaciones de trabajo están en pausa"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ninguna aplicación de trabajo"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ninguna aplicación personal"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay aplicaciones privadas"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador personal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluir texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluir enlace"</string>
<string name="include_link" msgid="827855767220339802">"Incluir enlace"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fijado"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagen seleccionable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo seleccionable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Elemento seleccionable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botón"</string>
</resources>
diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml
index fc1da949..6a17f5b3 100644
--- a/java/res/values-et/strings.xml
+++ b/java/res/values-et/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Pildi jagamine}other{# pildi jagamine}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video jagamine}other{# video jagamine}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# faili jagamine}other{# faili jagamine}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Jagatavate üksuste valimine"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Teksti sisaldava pildi jagamine}other{# teksti sisaldava pildi jagamine}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Linki sisaldava pildi jagamine}other{# linki sisaldava pildi jagamine}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Teksti sisaldava video jagamine}other{# teksti sisaldava video jagamine}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Linki sisaldava video jagamine}other{# linki sisaldava video jagamine}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Teksti sisaldava faili jagamine}other{# teksti sisaldava faili jagamine}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Linki sisaldava faili jagamine}other{# linki sisaldava faili jagamine}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albumi jagamine"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Ainult pilt}other{Ainult pildid}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Ainult video}other{Ainult videod}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ainult fail}other{Ainult failid}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sellele rakendusele pole antud salvestamise luba, kuid see saab heli jäädvustada selle USB-seadme kaudu."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Isiklik"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Töö"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privaatne"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Isiklik vaade"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Töövaade"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaatne vaade"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeeris teie IT-administraator"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Seda sisu ei saa töörakendustega jagada"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Seda sisu ei saa töörakendustega avada"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Seda sisu ei saa isiklike rakendustega jagada."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Seda sisu ei saa isiklike rakendustega avada"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Seda sisu ei saa privaatsete rakendustega jagada"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Seda sisu ei saa privaatsete rakendustega avada"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Töörakendused on peatatud"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Jätka"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Töörakendusi pole"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Isiklikke rakendusi pole"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Privaatseid rakendusi pole"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Kas avada <xliff:g id="APP">%s</xliff:g> teie isiklikul profiilil?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Kas avada <xliff:g id="APP">%s</xliff:g> teie tööprofiilil?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kasuta isiklikku brauserit"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Kaasa tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Välista link"</string>
<string name="include_link" msgid="827855767220339802">"Kaasa link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Kinnitatud"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Valitav pilt"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Valitav video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Valitav üksus"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Nupp"</string>
</resources>
diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml
index 0b45d815..e80edad4 100644
--- a/java/res/values-eu/strings.xml
+++ b/java/res/values-eu/strings.xml
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{eta beste # fitxategi}other{eta beste # fitxategi}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Partekatuko den testua"</string>
<string name="sharing_link" msgid="2307694372813942916">"Esteka partekatzen"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatzen}other{# irudi partekatzen}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatuko da}other{# irudi partekatuko dira}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bideoa partekatzen}other{# bideo partekatzen}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fitxategi partekatuko da}other{# fitxategi partekatuko dira}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Hautatu partekatu beharreko elementuak"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Irudi testudun bat partekatuko da}other{# irudi testudun partekatuko dira}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Irudi estekadun bat partekatuko da}other{# irudi estekadun partekatuko dira}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Bideo testudun bat partekatuko da}other{# bideo testudun partekatuko dira}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bideo estekadun bat partekatuko da}other{# bideo estekadun partekatuko dira}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fitxategi testudun bat partekatuko da}other{# fitxategi testudun partekatuko dira}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fitxategi estekadun bat partekatuko da}other{# fitxategi estekadun partekatuko dira}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albuma partekatzea"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Irudia soilik}other{Irudiak bakarrik}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bideoa soilik}other{Bideoak soilik}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fitxategia soilik}other{Fitxategiak soilik}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikazioak ez du grabatzeko baimenik, baina baliteke audioa grabatzea USB bidezko gailu horren bidez."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Pertsonala"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Lanekoa"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Pribatua"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ikuspegi pertsonala"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Laneko ikuspegia"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ikuspegi pribatua"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IKT saileko administratzaileak blokeatu egin du"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Eduki hau ezin da laneko aplikazioekin partekatu"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Eduki hau ezin da laneko aplikazioekin ireki"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Eduki hau ezin da aplikazio pertsonalekin partekatu"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Eduki hau ezin da aplikazio pertsonalekin ireki"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Eduki hau ezin da aplikazio pribatuekin partekatu"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Eduki hau ezin da aplikazio pribatuekin ireki"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pausatuta daude laneko aplikazioak"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Berraktibatu"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ez dago laneko aplikaziorik"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ez dago aplikazio pertsonalik"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aplikazio pribaturik ez"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Profil pertsonalean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Laneko profilean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Erabili arakatzaile pertsonala"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Sartu testua"</string>
<string name="exclude_link" msgid="1332778255031992228">"Utzi kanpoan esteka"</string>
<string name="include_link" msgid="827855767220339802">"Sartu esteka"</string>
+ <string name="pinned" msgid="7623664001331394139">"Ainguratuta"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Hauta daitekeen irudia"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Hauta daitekeen bideoa"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Hauta daitekeen elementua"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botoia"</string>
</resources>
diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml
index ba51a67e..71386d35 100644
--- a/java/res/values-fa/strings.xml
+++ b/java/res/values-fa/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"تصویربرداری با"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"گرفتن عکس با <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"تصویربرداری"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"استتفاده از یک برنامه دیگر"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"استفاده از یک برنامه دیگر"</string>
<string name="chooseActivity" msgid="6659724877523973446">"انتخاب کنش"</string>
<string name="noApplications" msgid="1139487441772284671">"‏هیچ برنامه‌ای نمی‌‎تواند این کار را انجام دهد."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"شما از این برنامه در خارج از نمایه کاری‌تان استفاده می‌کنید"</string>
@@ -60,39 +60,51 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{هم‌رسانی تصویر}one{هم‌رسانی ‍# تصویر}other{هم‌رسانی ‍# تصویر}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{درحال هم‌رسانی ویدیو}one{درحال هم‌رسانی # ویدیو}other{درحال هم‌رسانی # ویدیو}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{هم‌رسانی # فایل}one{هم‌رسانی # فایل}other{هم‌رسانی # فایل}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"انتخاب کردن موارد برای هم‌رسانی"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{درحال هم‌رسانی تصویر با نوشتار}one{درحال هم‌رسانی # تصویر با نوشتار}other{درحال هم‌رسانی # تصویر با نوشتار}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{درحال هم‌رسانی تصویر با پیوند}one{درحال هم‌رسانی # تصویر با پیوند}other{درحال هم‌رسانی # تصویر با پیوند}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{درحال هم‌رسانی ویدیو با نوشتار}one{درحال هم‌رسانی # ویدیو با نوشتار}other{درحال هم‌رسانی # ویدیو با نوشتار}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{درحال هم‌رسانی ویدیو با پیوند}one{درحال هم‌رسانی # ویدیو با پیوند}other{درحال هم‌رسانی # ویدیو با پیوند}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{درحال هم‌رسانی فایل با نوشتار}one{درحال هم‌رسانی # فایل با نوشتار}other{درحال هم‌رسانی # فایل با نوشتار}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{درحال هم‌رسانی فایل با پیوند}one{درحال هم‌رسانی # فایل با پیوند}other{درحال هم‌رسانی # فایل با پیوند}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"هم‌رسانی آلبوم"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{فقط تصویر}one{فقط تصویر}other{فقط تصویر}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{فقط ویدیو}one{فقط ویدیو}other{فقط ویدیو}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{فقط فایل}one{فقط فایل}other{فقط فایل}}"</string>
- <string name="image_preview_a11y_description" msgid="297102643932491797">"تصویر کوچک پیش‌نمای تصویر"</string>
- <string name="video_preview_a11y_description" msgid="683440858811095990">"تصویر کوچک پیش‌نمای ویدیو"</string>
- <string name="file_preview_a11y_description" msgid="7397224827802410602">"تصویر کوچک پیش‌نمای فایل"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"هیچ فردی توصیه نشده است که با او هم‌رسانی کنید"</string>
+ <string name="image_preview_a11y_description" msgid="297102643932491797">"ریزعکس پیش‌نمای تصویر"</string>
+ <string name="video_preview_a11y_description" msgid="683440858811095990">"ریزعکس پیش‌نمای ویدیو"</string>
+ <string name="file_preview_a11y_description" msgid="7397224827802410602">"ریزعکس پیش‌نمای فایل"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"هیچ فرد توصیه‌شده‌ای برای هم‌رسانی وجود ندارد"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"‏مجوز ضبط به این برنامه داده نشده است اما می‌تواند صدا را ازطریق این دستگاه USB ضبط کند."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"شخصی"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"کاری"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"خصوصی"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"نمای شخصی"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"نمای کاری"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نمای خصوصی"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"سرپرست فناوری اطلاعات آن را مسدود کرده است"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"نمی‌توان این محتوا را با برنامه‌های کاری هم‌رسانی کرد"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"نمی‌توان این محتوا را با برنامه‌های کاری باز کرد"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"نمی‌توان این محتوا را با برنامه‌های شخصی هم‌رسانی کرد"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"نمی‌توان این محتوا را با برنامه‌های شخصی باز کرد"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"نمی‌توان این محتوا را با برنامه‌های خصوصی هم‌رسانی کرد"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"نمی‌توان این محتوا را با برنامه‌های خصوصی باز کرد"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"برنامه‌های کاری موقتاً متوقف شده‌اند"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"لغو مکث"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"برنامه کاری‌ای وجود ندارد"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"برنامه شخصی‌ای وجود ندارد"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"بدون برنامه خصوصی"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> در نمایه شخصی باز شود؟"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> در نمایه کاری باز شود؟"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استفاده از مرورگر شخصی"</string>
<string name="miniresolver_use_work_browser" msgid="7892699758493230342">"استفاده از مرورگر کاری"</string>
<string name="exclude_text" msgid="5508128757025928034">"مستثنی کردن نوشتار"</string>
- <string name="include_text" msgid="642280283268536140">"لحاظ کردن نوشتار"</string>
+ <string name="include_text" msgid="642280283268536140">"گنجاندن نوشتار"</string>
<string name="exclude_link" msgid="1332778255031992228">"مستثنی کردن پیوند"</string>
- <string name="include_link" msgid="827855767220339802">"لحاظ کردن پیوند"</string>
+ <string name="include_link" msgid="827855767220339802">"گنجاندن پیوند"</string>
+ <string name="pinned" msgid="7623664001331394139">"سنجاق‌شده"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"تصویر قابل‌انتخاب"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ویدیو قابل‌انتخاب"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"مورد قابل‌انتخاب"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"دکمه"</string>
</resources>
diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml
index 792746b2..6938d4fa 100644
--- a/java/res/values-fi/strings.xml
+++ b/java/res/values-fi/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Jaetaan kuvaa}other{Jaetaan # kuvaa}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Jaetaan videota}other{Jaetaan # videota}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Jaetaan # tiedosto}other{Jaetaan # tiedostoa}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Valitse jaettavat kohteet"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Kuvaa ja tekstiä jaetaan}other{# kuvaa ja tekstiä jaetaan}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Kuvaa ja linkkiä jaetaan}other{# kuvaa ja linkkiä jaetaan}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Videota ja tekstiä jaetaan}other{# videota ja tekstiä jaetaan}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videota ja linkkiä jaetaan}other{# videota ja linkkiä jaetaan}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiedostoa ja tekstiä jaetaan}other{# tiedostoa ja tekstiä jaetaan}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiedostoa ja linkkiä jaetaan}other{# tiedostoa ja linkkiä jaetaan}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albumia jaetaan"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vain kuva}other{Vain kuvat}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vain video}other{Vain videot}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vain tiedostot}other{Vain tiedostot}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sovellus ei ole saanut tallennuslupaa mutta voi tallentaa ääntä tämän USB-laitteen avulla."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Henkilökohtainen"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Työ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Yksityinen"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Henkilökohtainen näkymä"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Työnäkymä"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Yksityinen näkymä"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT-järjestelmänvalvojasi estämä"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tätä sisältöä ei voi jakaa työsovelluksilla"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tätä sisältöä ei voi avata työsovelluksilla"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tätä sisältöä ei voi jakaa henkilökohtaisilla sovelluksilla"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tätä sisältöä ei voi avata henkilökohtaisilla sovelluksilla"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tätä sisältöä ei voi jakaa yksityisillä sovelluksilla"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tätä sisältöä ei voi avata yksityisillä sovelluksilla"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Työsovellukset on keskeytetty"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Jatka käyttöä"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ei työsovelluksia"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ei henkilökohtaisia sovelluksia"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ei yksityisiä sovelluksia"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Avataanko <xliff:g id="APP">%s</xliff:g> henkilökohtaisessa profiilissa?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Avataanko <xliff:g id="APP">%s</xliff:g> työprofiilissa?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Käytä henkilökohtaista selainta"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Liitä teksti mukaan"</string>
<string name="exclude_link" msgid="1332778255031992228">"Jätä linkki pois"</string>
<string name="include_link" msgid="827855767220339802">"Liitä linkki mukaan"</string>
+ <string name="pinned" msgid="7623664001331394139">"Kiinnitetty"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Valittava kuva"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Valittava video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Valittava kohde"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Painike"</string>
</resources>
diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml
index 49a74c8f..7fdda598 100644
--- a/java/res/values-fr-rCA/strings.xml
+++ b/java/res/values-fr-rCA/strings.xml
@@ -36,36 +36,38 @@
<string name="whichSendToApplication" msgid="2724450540348806267">"Envoyer avec"</string>
<string name="whichSendToApplicationNamed" msgid="1996548940365954543">"Envoyer avec <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichSendToApplicationLabel" msgid="6909037198280591110">"Envoyer"</string>
- <string name="whichHomeApplication" msgid="8797832422254564739">"Sélectionner une application pour l\'écran d\'accueil"</string>
- <string name="whichHomeApplicationNamed" msgid="3943122502791761387">"Utiliser <xliff:g id="APP">%1$s</xliff:g> comme application sur la page d\'accueil"</string>
+ <string name="whichHomeApplication" msgid="8797832422254564739">"Sélectionner une appli pour l\'écran d\'accueil"</string>
+ <string name="whichHomeApplicationNamed" msgid="3943122502791761387">"Utiliser <xliff:g id="APP">%1$s</xliff:g> comme appli sur la page d\'accueil"</string>
<string name="whichHomeApplicationLabel" msgid="2066319585322981524">"Enregistrer l\'image"</string>
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"Enregistrer l\'image avec"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"Enregistrer l\'image avec <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"Enregistrer l\'image"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"Utiliser une application différente"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"Utiliser une appli différente"</string>
<string name="chooseActivity" msgid="6659724877523973446">"Sélectionner une action"</string>
- <string name="noApplications" msgid="1139487441772284671">"Aucune application ne peut effectuer cette action."</string>
- <string name="forward_intent_to_owner" msgid="6454987608971162379">"Vous utilisez cette application en dehors de votre profil professionnel"</string>
- <string name="forward_intent_to_work" msgid="2906094223089139419">"Vous utilisez cette application dans votre profil professionnel"</string>
+ <string name="noApplications" msgid="1139487441772284671">"Aucune appli ne peut effectuer cette action."</string>
+ <string name="forward_intent_to_owner" msgid="6454987608971162379">"Vous utilisez cette appli en dehors de votre profil professionnel"</string>
+ <string name="forward_intent_to_work" msgid="2906094223089139419">"Vous utilisez cette appli dans votre profil professionnel"</string>
<string name="activity_resolver_use_always" msgid="8674194687637555245">"Toujours"</string>
<string name="activity_resolver_use_once" msgid="594173435998892989">"Une seule fois"</string>
<string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"<xliff:g id="APP">%1$s</xliff:g> ne prend pas en charge le profil professionnel"</string>
<string name="pin_specific_target" msgid="5057063421361441406">"Épingler <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="unpin_specific_target" msgid="3115158908159857777">"Annuler l\'épinglage de <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="screenshot_edit" msgid="3857183660047569146">"Modifier"</string>
- <string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # fichier}one{+ # fichier}many{+ # de fichiers}other{+ # fichiers}}"</string>
+ <string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # fichier}one{+ # fichier}many{+ # de fichiers}other{+ # fichiers}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{et # fichier supplémentaire}one{et # fichier supplémentaire}many{et # de fichiers supplémentaires}other{et # fichiers supplémentaires}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Partage de texte"</string>
<string name="sharing_link" msgid="2307694372813942916">"Partage d\'un lien"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partage d\'une image}one{Partage de # image}many{Partage de # d\'images}other{Partage de # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Partage de la vidéo…}one{Partage de # vidéo…}many{Partage de # de vidéos…}other{Partage de # vidéos…}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Partage de # fichier en cours…}one{Partage de # fichier en cours…}many{Partage de # de fichiers en cours…}other{Partage de # fichiers en cours…}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Sélectionner les éléments à partager"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Partage d\'une image avec du texte}one{Partage de # image avec du texte}many{Partage de # d\'images avec du texte}other{Partage de # images avec du texte}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Partage d\'une image avec un lien}one{Partage de # image avec un lien}many{Partage de # d\'images avec un lien}other{Partage de # images avec un lien}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Partage d\'une vidéo avec du texte}one{Partage de # vidéo avec du texte}many{Partage de # de vidéos avec du texte}other{Partage de # vidéos avec du texte}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partage d\'une vidéo avec un lien}one{Partage de # vidéo avec un lien}many{Partage de # de vidéos avec un lien}other{Partage de # vidéos avec un lien}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partage d\'un fichier avec du texte}one{Partage de # fichier avec du texte}many{Partage de # de fichiers avec du texte}other{Partage de # fichiers avec du texte}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partage d\'un fichier avec un lien}one{Partage de # fichier avec un lien}many{Partage de # de fichiers avec un lien}other{Partage de # fichiers avec un lien}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Album partagé"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string>
@@ -73,20 +75,25 @@
<string name="video_preview_a11y_description" msgid="683440858811095990">"Miniature d\'aperçu de la vidéo"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniature d\'aperçu du fichier"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Aucune recommandation de personnes avec lesquelles effectuer un partage"</string>
- <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas été autorisée à effectuer des enregistrements, mais elle pourrait capturer du contenu audio par l\'intermédiaire de cet appareil USB."</string>
+ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette appli n\'a pas été autorisée à effectuer des enregistrements, mais elle pourrait capturer du contenu audio par l\'intermédiaire de cet appareil USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Affichage personnel"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Affichage professionnel"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage privé"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string>
- <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applications professionnelles"</string>
- <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applications professionnelles"</string>
- <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applications personnelles"</string>
- <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applications personnelles"</string>
- <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applications professionnelles sont interrompues"</string>
+ <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applis professionnelles"</string>
+ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applis professionnelles"</string>
+ <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applis personnelles"</string>
+ <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applis personnelles"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applis privées"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applis privées"</string>
+ <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applis professionnelles sont interrompues"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string>
- <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune application professionnelle"</string>
- <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune application personnelle"</string>
+ <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune appli professionnelle"</string>
+ <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune appli personnelle"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune appli privée"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur du profil personnel"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inclure le texte"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclure le lien"</string>
<string name="include_link" msgid="827855767220339802">"Inclure le lien"</string>
+ <string name="pinned" msgid="7623664001331394139">"Épinglée"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Image sélectionnable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vidéo sélectionnable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Élément sélectionnable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Bouton"</string>
</resources>
diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml
index bfc7a4be..39d436a7 100644
--- a/java/res/values-fr/strings.xml
+++ b/java/res/values-fr/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Modifier"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # fichier}one{+ # fichier}many{+ # fichiers}other{+ # fichiers}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # autre fichier}one{+ # autre fichier}many{+ # autres fichiers}other{+ # autres fichiers}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Partage du texte…"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Texte à partager"</string>
<string name="sharing_link" msgid="2307694372813942916">"Partager le lien"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partage de l\'image…}one{Partage de # image…}many{Partage de # d\'images…}other{Partage de # images…}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partager l\'image}one{Partager # image}many{Partager # d\'images}other{Partager # images}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Partage de la vidéo…}one{Partage de # vidéo…}many{Partage de # de vidéos…}other{Partage de # vidéos…}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Partage de # fichier}one{Partage de # fichier}many{Partage de # fichiers}other{Partage de # fichiers}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Sélectionner les éléments à partager"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Partager 1 image avec du texte}one{Partager # image avec du texte}many{Partager # images avec du texte}other{Partager # images avec du texte}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Partager 1 image avec un lien}one{Partager # image avec un lien}many{Partager # images avec un lien}other{Partager # images avec un lien}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Partager 1 vidéo avec du texte}one{Partager # vidéo avec du texte}many{Partager # vidéos avec du texte}other{Partager # vidéos avec du texte}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partager 1 vidéo avec un lien}one{Partager # vidéo avec un lien}many{Partager # vidéos avec un lien}other{Partager # vidéos avec un lien}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partager 1 fichier avec du texte}one{Partager # fichier avec du texte}many{Partager # fichiers avec du texte}other{Partager # fichiers avec du texte}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partager 1 fichier avec un lien}one{Partager # fichier avec un lien}many{Partager # fichiers avec un lien}other{Partager # fichiers avec un lien}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Partage de l\'album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas reçu l\'autorisation d\'enregistrer des contenus audio, mais peut le faire via ce périphérique USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vue personnelle"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vue professionnelle"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage en mode privé"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applis professionnelles"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applis professionnelles"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applis personnelles"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applis personnelles"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applications privées"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applications privées"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applis professionnelles sont en pause"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune appli professionnelle"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune appli personnelle"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune application privée"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur personnel"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inclure le texte"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclure le lien"</string>
<string name="include_link" msgid="827855767220339802">"Inclure le lien"</string>
+ <string name="pinned" msgid="7623664001331394139">"Épinglée"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Image sélectionnable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vidéo sélectionnable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Élément sélectionnable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Bouton"</string>
</resources>
diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml
index c782666c..d45e982e 100644
--- a/java/res/values-gl/strings.xml
+++ b/java/res/values-gl/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartindo imaxe}other{Compartindo # imaxes}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartindo vídeo}other{Compartindo # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartindo # ficheiro}other{Compartindo # ficheiros}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Seleccionar elementos para compartir"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartindo imaxe con texto}other{Compartindo # imaxes con texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartindo imaxe con ligazón}other{Compartindo # imaxes con ligazón}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartindo vídeo con texto}other{Compartindo # vídeos con texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartindo vídeo con ligazón}other{Compartindo # vídeos con ligazón}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartindo ficheiro con texto}other{Compartindo # ficheiros con texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartindo ficheiro con ligazón}other{Compartindo # ficheiros con ligazón}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Compartindo álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Só a imaxe}other{Só as imaxes}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Só o vídeo}other{Só os vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Só o ficheiro}other{Só os ficheiros}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación non está autorizada a realizar gravacións, pero podería capturar audio a través deste dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Persoal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Traballo"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista persoal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de traballo"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"O teu administrador de TI bloqueou a instalación"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contido non pode compartirse con aplicacións do traballo"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contido non pode abrirse con aplicacións do traballo"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contido non pode compartirse con aplicacións persoais"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contido non pode abrirse con aplicacións persoais"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contido non se pode compartir con aplicacións privadas"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contido non se pode abrir con aplicacións privadas"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As aplicacións do traballo están en pausa"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Non hai ningunha aplicación do traballo compatible"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Non hai ningunha aplicación persoal compatible"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Non hai ningunha aplicación privada"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil persoal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil de traballo?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilizar navegador persoal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluír texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluír ligazón"</string>
<string name="include_link" msgid="827855767220339802">"Incluír ligazón"</string>
+ <string name="pinned" msgid="7623664001331394139">"Elemento fixado"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imaxe seleccionable"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo seleccionable"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Elemento seleccionable"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botón"</string>
</resources>
diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml
index 81cb2b2a..d0e65a18 100644
--- a/java/res/values-gu/strings.xml
+++ b/java/res/values-gu/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"આની સાથે છબી કૅપ્ચર કરો"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> વડે છબી કૅપ્ચર કરો"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"છબી કૅપ્ચર કરો"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"અલગ એપ્લિકેશનનો ઉપયોગ કરો"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"અલગ ઍપનો ઉપયોગ કરો"</string>
<string name="chooseActivity" msgid="6659724877523973446">"ક્રિયા પસંદ કરો"</string>
<string name="noApplications" msgid="1139487441772284671">"કોઈ ઍપ્લિકેશન આ ક્રિયા કરી શકતી નથી."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"તમે તમારી કાર્ય પ્રોફાઇલની બહાર આ એપ્લિકેશનનો ઉપયોગ કરી રહ્યાં છો"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{છબી શેર કરી રહ્યાં છીએ}one{# છબી શેર કરી રહ્યાં છીએ}other{# છબી શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{વીડિયો શેર કરીએ છીએ}one{# વીડિયો શેર કરીએ છીએ}other{# વીડિયો શેર કરીએ છીએ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ફાઇલ શેર કરી રહ્યાં છીએ}one{# ફાઇલ શેર કરી રહ્યાં છીએ}other{# ફાઇલ શેર કરી રહ્યાં છીએ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"શેર કરવા માટે આઇટમ પસંદ કરો"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ટેક્સ્ટ સાથે છબી શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # છબી શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # છબી શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{લિંક સાથે છબી શેર કરી રહ્યાં છીએ}one{લિંક સાથે # છબી શેર કરી રહ્યાં છીએ}other{લિંક સાથે # છબી શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ટેક્સ્ટ સાથે વીડિયો શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # વીડિયો શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # વીડિયો શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{લિંક સાથે વીડિયો શેર કરી રહ્યાં છીએ}one{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}other{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ટેક્સ્ટ સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{લિંક સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"આલ્બમ શેર કરી રહ્યાં છીએ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{માત્ર છબી}one{માત્ર છબી}other{માત્ર છબીઓ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ફક્ત વીડિયો}one{ફક્ત વીડિયો}other{ફક્ત વીડિયો}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ફક્ત ફાઇલ}one{ફક્ત ફાઇલ}other{ફક્ત ફાઇલો}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"આ ઍપને રેકૉર્ડ કરવાની પરવાનગી આપવામાં આવી નથી પરંતુ તે આ USB ડિવાઇસ મારફતે ઑડિયો કૅપ્ચર કરી શકે છે."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"વ્યક્તિગત"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ઑફિસ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ખાનગી"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"વ્યક્તિગત વ્યૂ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ઑફિસ વ્યૂ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ખાનગી વ્યૂ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"તમારા IT વ્યવસ્થાપકે બ્લૉક કર્યું છે"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ સાથે શેર કરી શકાતું નથી"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ વડે ખોલી શકાતું નથી"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ સાથે શેર કરી શકાતું નથી"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ વડે ખોલી શકાતું નથી"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"આ કન્ટેન્ટ ખાનગી ઍપ સાથે શેર કરી શકાતું નથી"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"આ કન્ટેન્ટ ખાનગી ઍપ વડે ખોલી શકાતું નથી"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ઑફિસ માટેની ઍપ થોભાવવામાં આવી છે"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ફરી ચાલુ કરો"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"કોઈ ઑફિસ માટેની ઍપ સપોર્ટ કરતી નથી"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"કોઈ વ્યક્તિગત ઍપ સપોર્ટ કરતી નથી"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"કોઈ ખાનગી ઍપ નથી"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"તમારી વ્યક્તિગત પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"તમારી ઑફિસની પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"વ્યક્તિગત બ્રાઉઝરનો ઉપયોગ કરો"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ટેક્સ્ટ શામેલ કરો"</string>
<string name="exclude_link" msgid="1332778255031992228">"લિંકને બાકાત કરો"</string>
<string name="include_link" msgid="827855767220339802">"લિંક શામેલ કરો"</string>
+ <string name="pinned" msgid="7623664001331394139">"પિન કરેલી"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"પસંદ કરી શકાય તેવી છબી"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"પસંદ કરી શકાય તેવો વીડિયો"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"પસંદ કરી શકાય તેવી આઇટમ"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"બટન"</string>
</resources>
diff --git a/java/res/values-h480dp/dimens.xml b/java/res/values-h480dp/dimens.xml
index b5c86c77..74fab4ea 100644
--- a/java/res/values-h480dp/dimens.xml
+++ b/java/res/values-h480dp/dimens.xml
@@ -22,7 +22,7 @@
<dimen name="resolver_button_bar_spacing">8dp</dimen>
<dimen name="chooser_preview_width">-1px</dimen>
- <dimen name="chooser_preview_image_height_tall">192dp</dimen>
+ <dimen name="chooser_preview_image_height_tall">284dp</dimen>
<dimen name="grid_padding">10dp</dimen>
<dimen name="width_text_image_preview_size">56dp</dimen>
</resources>
diff --git a/java/res/values-h480dp/integers.xml b/java/res/values-h480dp/integers.xml
index c1693057..1195d6b7 100644
--- a/java/res/values-h480dp/integers.xml
+++ b/java/res/values-h480dp/integers.xml
@@ -15,5 +15,5 @@
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
- <integer name="text_preview_lines">3</integer>
+ <integer name="text_preview_lines">8</integer>
</resources>
diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml
index 899aa1cf..70da0c22 100644
--- a/java/res/values-hi/strings.xml
+++ b/java/res/values-hi/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{इमेज शेयर की जा रही है}one{# इमेज शेयर की जा रही है}other{# इमेज शेयर की जा रही हैं}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{वीडियो शेयर किया जा रहा है}one{# वीडियो शेयर किया जा रहा है}other{# वीडियो शेयर किए जा रहे हैं}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# फ़ाइल शेयर की जा रही है}one{# फ़ाइल शेयर की जा रही है}other{# फ़ाइलें शेयर की जा रही हैं}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"शेयर करने के लिए आइटम चुनें"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{टेक्स्ट के साथ इमेज शेयर की जा रही है}one{टेक्स्ट के साथ # इमेज शेयर की जा रही है}other{टेक्स्ट के साथ # इमेज शेयर की जा रही हैं}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंक के साथ इमेज शेयर की जा रही है}one{लिंक के साथ # इमेज शेयर की जा रही है}other{लिंक के साथ # इमेज शेयर की जा रही हैं}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{टेक्स्ट के साथ वीडियो शेयर किया जा रहा है}one{टेक्स्ट के साथ # वीडियो शेयर किया जा रहा है}other{टेक्स्ट के साथ # वीडियो शेयर किए जा रहे हैं}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक के साथ वीडियो शेयर किया जा रहा है}one{लिंक के साथ # वीडियो शेयर किया जा रहा है}other{लिंक के साथ # वीडियो शेयर किए जा रहे हैं}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट के साथ फ़ाइल शेयर की जा रही है}one{टेक्स्ट के साथ # फ़ाइल शेयर की जा रही है}other{टेक्स्ट के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक के साथ फ़ाइल शेयर की जा रही है}one{लिंक के साथ # फ़ाइल शेयर की जा रही है}other{लिंक के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"एल्बम शेयर किया जा रहा है"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{सिर्फ़ इमेज}one{सिर्फ़ इमेज}other{सिर्फ़ इमेज}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{सिर्फ़ वीडियो}one{सिर्फ़ वीडियो}other{सिर्फ़ वीडियो}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{सिर्फ़ फ़ाइल}one{सिर्फ़ फ़ाइल}other{सिर्फ़ फ़ाइलें}}"</string>
@@ -74,19 +76,24 @@
<string name="file_preview_a11y_description" msgid="7397224827802410602">"फ़ाइल के थंबनेल की झलक"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"शेयर करने के लिए, किसी व्यक्ति का सुझाव नहीं दिया गया है"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"इस ऐप्लिकेशन को रिकॉर्ड करने की अनुमति नहीं दी गई है. हालांकि, ऐप्लिकेशन इस यूएसबी डिवाइस से ऐसा कर सकता है."</string>
- <string name="resolver_personal_tab" msgid="1381052735324320565">"निजी प्रोफ़ाइल"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"निजी"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"वर्क प्रोफ़ाइल"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"प्राइवेट"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"निजी व्यू"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"वर्क व्यू"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी व्यू"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"आपके आईटी एडमिन ने इस कॉन्टेंट को शेयर करने की सुविधा ब्लॉक कर रखी है"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन का इस्तेमाल करके, शेयर नहीं किया जा सकता"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन पर खोला नहीं जा सकता"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"इस कॉन्टेंट को निजी ऐप्लिकेशन के ज़रिए शेयर नहीं किया जा सकता"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर शेयर नहीं किया जा सकता"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"इस कॉन्टेंट को निजी ऐप्लिकेशन की मदद से नहीं खोला जा सकता"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"वर्क ऐप्लिकेशन बंद किए गए हैं"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"चालू करें"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यह कॉन्टेंट, ऑफ़िस के काम से जुड़े आपके किसी भी ऐप्लिकेशन पर खोला नहीं जा सकता"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यह कॉन्टेंट आपके किसी भी निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोई निजी ऐप्लिकेशन उपलब्ध नहीं है"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"क्या <xliff:g id="APP">%s</xliff:g> को निजी प्रोफ़ाइल में खोलना है?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"क्या <xliff:g id="APP">%s</xliff:g> को वर्क प्रोफ़ाइल में खोलना है?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"निजी ब्राउज़र का इस्तेमाल करें"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"टेक्स्ट जोड़ें"</string>
<string name="exclude_link" msgid="1332778255031992228">"लिंक हटाएं"</string>
<string name="include_link" msgid="827855767220339802">"लिंक जोड़ें"</string>
+ <string name="pinned" msgid="7623664001331394139">"पिन किया गया"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ऐसी इमेज जिसे चुना जा सकता है"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ऐसा वीडियो जिसे चुना जा सकता है"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ऐसा आइटम जिसे चुना जा सकता है"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"बटन"</string>
</resources>
diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml
index c5ef7283..c8f8c90d 100644
--- a/java/res/values-hr/strings.xml
+++ b/java/res/values-hr/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # datoteka}one{+ # datoteka}few{+ # datoteke}other{+ # datoteka}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{i još # datoteka}one{i još # datoteka}few{i još # datoteke}other{i još # datoteka}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Dijeli se tekst"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Dijeli se veza"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeli se slika}one{Dijeli se # slika}few{Dijele se # slike}other{Dijeli se # slika}}"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Veza za dijeljenje"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Podijelite sliku}one{Podijelite # sliku}few{Podijelite # slike}other{Podijelite # slika}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Dijeli se videozapis}one{Dijeli se # videozapis}few{Dijele se # videozapisa}other{Dijeli se # videozapisa}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeli se # datoteka}one{Dijeli se # datoteka}few{Dijele se # datoteke}other{Dijeli se # datoteka}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Odaberite stavke za dijeljenje"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeli se slika s tekstom}one{Dijeli se # slika s tekstom}few{Dijele se # slike s tekstom}other{Dijeli se # slika s tekstom}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Dijeli se slika s vezom}one{Dijeli se # slika s vezom}few{Dijele se # slike s vezom}other{Dijeli se # slika s vezom}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Dijeli se videozapis s tekstom}one{Dijeli se # videozapis s tekstom}few{Dijele se # videozapisa s tekstom}other{Dijeli se # videozapisa s tekstom}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeli se videozapis s vezom}one{Dijeli se # videozapis s vezom}few{Dijele se # videozapisa s vezom}other{Dijeli se # videozapisa s vezom}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeli se datoteka s tekstom}one{Dijeli se # datoteka s tekstom}few{Dijele se # datoteke s tekstom}other{Dijeli se # datoteka s tekstom}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeli se datoteka s vezom}one{Dijeli se # datoteka s vezom}few{Dijele se # datoteke s vezom}other{Dijeli se # datoteka s vezom}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija nema dopuštenje za snimanje, no mogla bi primati zvuk putem ovog USB uređaja."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Osobno"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobni prikaz"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Poslovni prikaz"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao vaš IT administrator"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Taj se sadržaj ne može dijeliti pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Taj se sadržaj ne može otvoriti pomoću poslovnih aplikacija"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Taj se sadržaj ne može dijeliti pomoću osobnih aplikacija"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Taj se sadržaj ne može otvoriti pomoću osobnih aplikacija"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Taj se sadržaj ne može dijeliti pomoću privatnih aplikacija"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Taj se sadržaj ne može otvoriti pomoću privatnih aplikacija"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovno pokreni"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Poslovne aplikacije nisu dostupne"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Osobne aplikacije nisu dostupne"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na osobnom profilu?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na poslovnom profilu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi osobni preglednik"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Uključi tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Isključi vezu"</string>
<string name="include_link" msgid="827855767220339802">"Uključi vezu"</string>
+ <string name="pinned" msgid="7623664001331394139">"Prikvačeno"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Slika koja se može odabrati"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Videozapis koji se može odabrati"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Stavka koja se može odabrati"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Gumb"</string>
</resources>
diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml
index 4ad60f92..a9e5e820 100644
--- a/java/res/values-hu/strings.xml
+++ b/java/res/values-hu/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Kép megosztása}other{# kép megosztása}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Videó megosztása}other{# videó megosztása}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fájl megosztása}other{# fájl megosztása}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Válassza ki a megosztani kívánt elemeket"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Kép megosztása szöveggel}other{# kép megosztása szöveggel}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Kép megosztása linkkel}other{# kép megosztása linkkel}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Videó megosztása szöveggel}other{# videó megosztása szöveggel}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videó megosztása linkkel}other{# videó megosztása linkkel}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fájl megosztása szöveggel}other{# fájl megosztása szöveggel}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fájl megosztása linkkel}other{# fájl megosztása linkkel}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Album megosztása"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Csak kép}other{Csak képek}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Csak videó}other{Csak videók}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Csak fájl}other{Csak fájlok}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ez az alkalmazás nem rendelkezik rögzítési engedéllyel, de ezzel az USB-eszközzel képes a hangfelvételre."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Személyes"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Munka"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privát"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Személyes nézet"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Munkanézet"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privát nézet"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Rendszergazda által letiltva"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ez a tartalom nem osztható meg munkahelyi alkalmazásokkal"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ez a tartalom nem nyitható meg munkahelyi alkalmazásokkal"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ez a tartalom nem osztható meg személyes alkalmazásokkal"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ez a tartalom nem nyitható meg személyes alkalmazásokkal"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ez a tartalom nem osztható meg privát alkalmazásokkal"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ez a tartalom nem nyitható meg privát alkalmazásokkal"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"A munkahelyi alkalmazások szüneteltetve vannak"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Szüneteltetés feloldása"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nincs munkahelyi alkalmazás"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nincs személyes alkalmazás"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nincsenek privát alkalmazások"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a személyes profil használatával?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a munkaprofil használatával?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Személyes böngésző használata"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Szöveggel együtt"</string>
<string name="exclude_link" msgid="1332778255031992228">"Link eltávolítása"</string>
<string name="include_link" msgid="827855767220339802">"Linkkel együtt"</string>
+ <string name="pinned" msgid="7623664001331394139">"Kitűzve"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Kijelölhető kép"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Kijelölhető videó"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Kijelölhető elem"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Gomb"</string>
</resources>
diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml
index 3db5ed02..b0b0b235 100644
--- a/java/res/values-hy/strings.xml
+++ b/java/res/values-hy/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Պատկերի ուղարկում}one{# պատկերի ուղարկում}other{# պատկերի ուղարկում}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Տեսանյութի ուղարկում}one{# տեսանյութի ուղարկում}other{# տեսանյութի ուղարկում}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Ուղարկվում է # ֆայլ}one{Ուղարկվում է # ֆայլ}other{Ուղարկվում է # ֆայլ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Ընտրեք տարրեր՝ կիսվելու համար"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}one{# պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}other{# պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Պատկերի ուղարկում հղման միջոցով}one{# պատկերի ուղարկում հղման միջոցով}other{# պատկերի ուղարկում հղման միջոցով}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}one{# տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}other{# տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Տեսանյութի ուղարկում հղման միջոցով}one{# տեսանյութի ուղարկում հղման միջոցով}other{# տեսանյութի ուղարկում հղման միջոցով}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}one{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}other{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Ֆայլի ուղարկում հղման միջոցով}one{# ֆայլի ուղարկում հղման միջոցով}other{# ֆայլի ուղարկում հղման միջոցով}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Ալբոմը դարձվում է ընդհանուր"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Միայն պատկերը}one{Միայն պատկերը}other{Միայն պատկերները}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Միայն տեսանյութը}one{Միայն տեսանյութը}other{Միայն տեսանյութերը}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Միայն ֆայլը}one{Միայն ֆայլը}other{Միայն ֆայլերը}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Հավելվածը ձայնագրելու թույլտվություն չունի, սակայն կկարողանա գրանցել ձայնն այս USB սարքի միջոցով։"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Անձնական"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Աշխատանքային"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Մասնավոր"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Անձնական"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Աշխատանքային"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Անձնական դիտակերպ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Արգելափակվել է ձեր ՏՏ ադմինիստրատորի կողմից"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Այս բովանդակությունը հնարավոր չէ ուղարկել աշխատանքային հավելվածներով"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Այս բովանդակությունը հնարավոր չէ բացել աշխատանքային հավելվածներով"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Այս բովանդակությունը հնարավոր չէ ուղարկել անձնական հավելվածներով"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Այս բովանդակությունը հնարավոր չէ բացել անձնական հավելվածներով"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Այս բովանդակությամբ հնարավոր չէ կիսվել մասնավոր հավելվածներով"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Այս բովանդակությունը հնարավոր չէ բացել մասնավոր հավելվածներով"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Աշխատանքային հավելվածները դադարեցված են"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Նորից միացնել"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Աշխատանքային հավելվածներ չկան"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Անձնական հավելվածներ չկան"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Անձնական հավելվածները չեն աջակցում որոշակի բովանդակություն"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր անձնական պրոֆիլում"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր աշխատանքային պրոֆիլում"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Օգտագործել անձնական դիտարկիչը"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Ներառել տեքստը"</string>
<string name="exclude_link" msgid="1332778255031992228">"Բացառել հղումը"</string>
<string name="include_link" msgid="827855767220339802">"Ներառել հղումը"</string>
+ <string name="pinned" msgid="7623664001331394139">"Ամրացված է"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Ընտրելու հնարավորությամբ պատկեր"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Ընտրելու հնարավորությամբ տեսանյութ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Ընտրելու հնարավորությամբ տարր"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Կոճակ"</string>
</resources>
diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml
index 74e5edb4..86828b7c 100644
--- a/java/res/values-in/strings.xml
+++ b/java/res/values-in/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"Jepret gambar dengan"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"Ambil gambar dengan <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"Jepret gambar"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"Gunakan aplikasi yang berbeda"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"Gunakan aplikasi lain"</string>
<string name="chooseActivity" msgid="6659724877523973446">"Pilih tindakan"</string>
<string name="noApplications" msgid="1139487441772284671">"Tidak ada apl yang dapat melakukan tindakan ini."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"Anda menggunakan aplikasi ini di luar profil kerja"</string>
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Edit"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # file}other{+ # file}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # file lainnya}other{+ # file lainnya}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Berbagi teks"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Teks yang akan dibagikan"</string>
<string name="sharing_link" msgid="2307694372813942916">"Berbagi link"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Berbagi gambar}other{Berbagi # gambar}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Membagikan video}other{Membagikan # video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Membagikan # file}other{Membagikan # file}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Pilih item untuk dibagikan"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Membagikan gambar dengan teks}other{Membagikan # gambar dengan teks}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Membagikan gambar dengan link}other{Membagikan # gambar dengan link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Membagikan video dengan teks}other{Membagikan # video dengan teks}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Membagikan video dengan link}other{Membagikan # video dengan link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Membagikan file dengan teks}other{Membagikan # file dengan teks}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Membagikan file dengan link}other{Membagikan # file dengan link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Berbagi album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Khusus gambar}other{Khusus gambar}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Khusus video}other{Khusus video}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Khusus file}other{Khusus file}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikasi ini tidak diberi izin merekam, tetapi dapat merekam audio melalui perangkat USB ini."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Pribadi"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privasi"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Tampilan pribadi"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Tampilan kerja"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Tampilan pribadi"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Diblokir oleh admin IT Anda"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Konten ini tidak dapat dibagikan dengan aplikasi kerja"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Konten ini tidak dapat dibuka dengan aplikasi kerja"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Konten ini tidak dapat dibagikan ke aplikasi pribadi"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Konten ini tidak dapat dibuka dengan aplikasi pribadi"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Konten ini tidak dapat dibagikan dengan aplikasi yang ada di profil privasi"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Konten ini tidak dapat dibuka dengan aplikasi yang ada di profil privasi"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikasi kerja dijeda"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Batalkan jeda"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tidak ada aplikasi kerja"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tidak ada aplikasi pribadi"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tidak ada aplikasi pribadi"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> di profil pribadi?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> di profil kerja?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan browser pribadi"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Sertakan teks"</string>
<string name="exclude_link" msgid="1332778255031992228">"Kecualikan link"</string>
<string name="include_link" msgid="827855767220339802">"Sertakan link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Disematkan"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Gambar yang dapat dipilih"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video yang dapat dipilih"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Item yang dapat dipilih"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Tombol"</string>
</resources>
diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml
index f5664058..9125bae9 100644
--- a/java/res/values-is/strings.xml
+++ b/java/res/values-is/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deilir mynd}one{Deilir # mynd}other{Deilir # myndum}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deilir myndskeiði}one{Deilir # myndskeiði}other{Deilir # myndskeiðum}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deilir # skrá}one{Deilir # skrá}other{Deilir # skrám}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Veldu atriði til að deila"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deilir mynd með texta}one{Deilir # mynd með texta}other{Deilir # myndum með texta}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deilir mynd með tengli}one{Deilir # mynd með tengli}other{Deilir # myndum með tengli}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deilir myndskeiði með texta}one{Deilir # myndskeiði með texta}other{Deilir # myndskeiðum með texta}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deilir myndskeiði með tengli}one{Deilir # myndskeiði með tengli}other{Deilir # myndskeiðum með tengli}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deilir skrá með texta}one{Deilir # skrá með texta}other{Deilir # skrám með texta}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deilir skrá með tengli}one{Deilir # skrá með tengli}other{Deilir # skrám með tengli}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deilir albúmi"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Eingöngu mynd}one{Eingöngu myndir}other{Eingöngu myndir}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Eingöngu myndskeið}one{Eingöngu myndskeið}other{Eingöngu myndskeið}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Eingöngu skrá}one{Eingöngu skrár}other{Eingöngu skrár}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Þetta forrit hefur ekki fengið heimild fyrir upptöku en gæti tekið upp hljóð í gegnum þetta USB-tæki."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Persónulegt"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Vinna"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Lokað"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persónulegt yfirlit"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vinnuyfirlit"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Lokuð stilling"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Útilokað af kerfisstjóra"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ekki er hægt að deila þessu efni með vinnuforritum"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ekki er hægt að opna þetta efni með vinnuforritum"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ekki er hægt að deila þessu efni með forritum til einkanota"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ekki er hægt að opna þetta efni með forritum til einkanota"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ekki er hægt að deila þessu efni með einkaforritum"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ekki er hægt að opna þetta efni með einkaforritum"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Hlé gert á vinnuforritum"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Ljúka hléi"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Engin vinnuforrit"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Engin forrit til einkanota"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Engin einkaforrit"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Opna <xliff:g id="APP">%s</xliff:g> í þínu eigin sniði?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Opna <xliff:g id="APP">%s</xliff:g> í vinnusniðinu þínu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Nota einkavafra"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Hafa texta með"</string>
<string name="exclude_link" msgid="1332778255031992228">"Útiloka tengil"</string>
<string name="include_link" msgid="827855767220339802">"Hafa tengil með"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fest"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Mynd sem hægt er að velja"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeó sem hægt er að velja"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Atriði sem hægt er að velja"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Hnappur"</string>
</resources>
diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml
index 7f861f52..7d0a7fa7 100644
--- a/java/res/values-it/strings.xml
+++ b/java/res/values-it/strings.xml
@@ -53,19 +53,21 @@
<string name="pin_specific_target" msgid="5057063421361441406">"Fissa <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="unpin_specific_target" msgid="3115158908159857777">"Sblocca <xliff:g id="LABEL">%1$s</xliff:g>"</string>
<string name="screenshot_edit" msgid="3857183660047569146">"Modifica"</string>
- <string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # file}many{+ # file}other{+ # file}}"</string>
- <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # altro file}many{+ altri # file}other{+ altri # file}}"</string>
+ <string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # file}many{+ # di file}other{+ # file}}"</string>
+ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # altro file}many{+ altri # di file}other{+ altri # file}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Condivisione del testo"</string>
<string name="sharing_link" msgid="2307694372813942916">"Condivisione del link"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Condivisione dell\'immagine}many{Condivisione di # immagini}other{Condivisione di # immagini}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Condivisione del video…}many{Condivisione di # video…}other{Condivisione di # video…}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Condivisione di # file in corso…}many{Condivisione di # file in corso…}other{Condivisione di # file in corso…}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Seleziona gli elementi da condividere"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Condivisione immagine con testo in corso…}many{Condivisione # immagini con testo in corso…}other{Condivisione # immagini con testo in corso…}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Condivisione immagine con link}many{Condivisione # immagini con link}other{Condivisione # immagini con link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Condivisione video con messaggio in corso…}many{Condivisione # video con messaggio in corso…}other{Condivisione # video con messaggio in corso…}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Condivisione video con link in corso…}many{Condivisione # video con link in corso…}other{Condivisione # video con link in corso…}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Condivisione file con messaggio in corso…}many{Condivisione # file con messaggio in corso…}other{Condivisione # file con messaggio in corso…}}"</string>
- <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Condivisione file con link in corso…}many{Condivisione # file con link in corso…}other{Condivisione # file con link in corso…}}"</string>
+ <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Condivisione file con link in corso…}many{Condivisione # di file con link in corso…}other{Condivisione # file con link in corso…}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Condivisione album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Soltanto l\'immagine}many{Soltanto le immagini}other{Soltanto le immagini}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Soltanto il video}many{Soltanto i video}other{Soltanto i video}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Soltanto il file}many{Soltanto i file}other{Soltanto i file}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"A questa app non è stata concessa l\'autorizzazione di registrazione, ma l\'app potrebbe acquisire l\'audio tramite questo dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personale"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Lavoro"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privato"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualizzazione personale"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualizzazione di lavoro"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualizzazione privata"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloccati dall\'amministratore IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Questi contenuti non possono essere condivisi con app di lavoro"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Questi contenuti non possono essere aperti con app di lavoro"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Questi contenuti non possono essere condivisi con app personali"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Questi contenuti non possono essere aperti con app personali"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Questi contenuti non possono essere condivisi con app private"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Questi contenuti non possono essere aperti con app private"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Le app di lavoro sono in pausa"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Riattiva"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nessuna app di lavoro"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nessuna app personale"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nessuna app privata"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo personale?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo di lavoro?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usa il browser personale"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Includi testo"</string>
<string name="exclude_link" msgid="1332778255031992228">"Escludi link"</string>
<string name="include_link" msgid="827855767220339802">"Includi link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Elemento fissato"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Immagine selezionabile"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video selezionabile"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Elemento selezionabile"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Pulsante"</string>
</resources>
diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml
index 50bdc170..43921c78 100644
--- a/java/res/values-iw/strings.xml
+++ b/java/res/values-iw/strings.xml
@@ -60,33 +60,40 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{שיתוף של תמונה}one{שיתוף של # תמונות}two{שיתוף של # תמונות}other{שיתוף של # תמונות}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{מתבצע שיתוף של סרטון}one{מתבצע שיתוף של # סרטונים}two{מתבצע שיתוף של # סרטונים}other{מתבצע שיתוף של # סרטונים}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{מתבצע שיתוף של קובץ אחד}one{מתבצע שיתוף של # קבצים}two{מתבצע שיתוף של # קבצים}other{מתבצע שיתוף של # קבצים}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"בחירת פריטים לשיתוף"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{שיתוף תמונה עם טקסט}one{שיתוף # תמונות עם טקסט}two{שיתוף # תמונות עם טקסט}other{שיתוף # תמונות עם טקסט}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{שיתוף תמונה עם קישור}one{שיתוף # תמונות עם קישור}two{שיתוף # תמונות עם קישור}other{שיתוף # תמונות עם קישור}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{שיתוף סרטון עם טקסט}one{שיתוף # סרטונים עם טקסט}two{שיתוף # סרטונים עם טקסט}other{שיתוף # סרטונים עם טקסט}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{שיתוף סרטון עם קישור}one{שיתוף # סרטונים עם קישור}two{שיתוף # סרטונים עם קישור}other{שיתוף # סרטונים עם קישור}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{שיתוף קובץ עם טקסט}one{שיתוף # קבצים עם טקסט}two{שיתוף # קבצים עם טקסט}other{שיתוף # קבצים עם טקסט}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{שיתוף תמונה עם קישור}one{שיתוף # תמונות עם קישור}two{שיתוף # תמונות עם קישור}other{שיתוף # תמונות עם קישור}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"שיתוף האלבום"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{תמונה בלבד}one{תמונות בלבד}two{תמונות בלבד}other{תמונות בלבד}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{סרטון בלבד}one{סרטונים בלבד}two{סרטונים בלבד}other{סרטונים בלבד}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{קובץ בלבד}one{קבצים בלבד}two{קבצים בלבד}other{קבצים בלבד}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"תמונה ממוזערת של תצוגה מקדימה של תמונה"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"תמונה ממוזערת של תצוגה מקדימה של סרטון"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"תמונה ממוזערת של תצוגה מקדימה של קובץ"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"אין אנשים שניתן לשתף איתם"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"אין המלצות עם מי לשתף"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"‏לאפליקציה זו לא ניתנה הרשאת הקלטה, אבל אפשר להקליט אודיו באמצעות התקן ה-USB הזה."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"אישי"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"עבודה"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"מרחב פרטי"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"תצוגה אישית"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"תצוגת עבודה"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"תצוגה פרטית"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"‏נחסם על ידי מנהל ה-IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"אי אפשר לשתף את התוכן הזה עם אפליקציות לעבודה"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לעבודה"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"אי אפשר לשתף את התוכן הזה עם אפליקציות לשימוש אישי"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לשימוש אישי"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"אי אפשר לשתף את התוכן הזה עם אפליקציות פרטיות"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"אי אפשר לפתוח את התוכן הזה באפליקציות פרטיות"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"האפליקציות לעבודה מושהות"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ביטול ההשהיה"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"אין אפליקציות לעבודה"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"אין אפליקציות לשימוש אישי"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"התוכן הזה לא זמין לאפליקציות פרטיות"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל האישי?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל העבודה?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"בדפדפן האישי"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"הכללת הטקסט"</string>
<string name="exclude_link" msgid="1332778255031992228">"החרגת הקישור"</string>
<string name="include_link" msgid="827855767220339802">"הכללת הקישור"</string>
+ <string name="pinned" msgid="7623664001331394139">"מוצמד"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"תמונה שניתן לבחור"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"סרטון שניתן לבחור"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"פריט שניתן לבחור"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"כפתור"</string>
</resources>
diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml
index 9133baee..094106c3 100644
--- a/java/res/values-ja/strings.xml
+++ b/java/res/values-ja/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"編集"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{他 # 件のファイル}other{他 # 件のファイル}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{その他 # ファイル}other{その他 # ファイル}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"テキストを共有中"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"テキストの共有"</string>
<string name="sharing_link" msgid="2307694372813942916">"リンクを共有中"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{画像を共有しています}other{# 枚の画像を共有しています}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{1 枚の画像を共有します}other{# 枚の画像を共有します}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{動画を共有中}other{# 個の動画を共有中}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# 個のファイルを共有中}other{# 個のファイルを共有中}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"共有するアイテムの選択"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{テキスト付き画像を共有しています}other{テキスト付き画像を # 件共有しています}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{リンク付き画像を共有しています}other{リンク付き画像を # 件共有しています}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{テキスト付き動画を共有中}other{テキスト付き動画を # 件共有中}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{リンク付き動画を共有中}other{リンク付き動画を # 件共有中}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{テキスト付きファイルを共有中}other{テキスト付きファイルを # 件共有中}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{リンク付きファイルを共有中}other{リンク付きファイルを # 件共有中}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"アルバムの共有"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{画像のみ}other{画像のみ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{動画のみ}other{動画のみ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ファイルのみ}other{ファイルのみ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"このアプリに録音権限は付与されていませんが、この USB デバイスから音声を収集できるようになります。"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"個人用"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"仕事用"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"プライベート"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人用ビュー"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"仕事用ビュー"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"プライベート ビュー"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理者によりブロックされました"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"このコンテンツを仕事用アプリと共有することはできません"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"このコンテンツを仕事用アプリで開くことはできません"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"このコンテンツを個人用アプリと共有することはできません"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"このコンテンツを個人用アプリで開くことはできません"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"このコンテンツを限定公開アプリと共有することはできません"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"このコンテンツを限定公開アプリで開くことはできません"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"仕事用アプリ一時停止中"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"一時停止を解除"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"仕事用アプリはありません"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"個人用アプリはありません"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"限定公開アプリは対応していません"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"個人用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"仕事用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"個人用ブラウザを使用"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"テキストを含める"</string>
<string name="exclude_link" msgid="1332778255031992228">"リンクを除外"</string>
<string name="include_link" msgid="827855767220339802">"リンクを含める"</string>
+ <string name="pinned" msgid="7623664001331394139">"固定されています"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"選択可能な画像"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"選択可能な動画"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"選択可能なアイテム"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ボタン"</string>
</resources>
diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml
index 5a47bbac..e0951e39 100644
--- a/java/res/values-ka/strings.xml
+++ b/java/res/values-ka/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ზიარდება სურათი}other{ზიარდება # სურათი}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ზიარდება ვიდეო}other{ზიარდება # ვიდეო}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{ზიარდება # ფაილი}other{ზიარდება # ფაილი}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"გასაზიარებელი ერთეულების არჩევა"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{სურათი ზიარდება ტექსტით}other{# სურათი ზიარდება ტექსტით}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{სურათი ზიარდება ბმულით}other{# სურათი ზიარდება ბმულით}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ვიდეო ზიარდება ტექსტით}other{# ვიდეო ზიარდება ტექსტით}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ვიდეო ზიარდება ბმულით}other{# ვიდეო ზიარდება ბმულით}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ფაილი ზიარდება ტექსტით}other{# ფაილი ზიარდება ტექსტით}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ფაილი ზიარდება ბმულით}other{# ფაილი ზიარდება ბმულით}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"გაზიარებული ალბომი"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{მხოლოდ სურათი}other{მხოლოდ სურათები}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{მხოლოდ ვიდეო}other{მხოლოდ ვიდეოები}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{მხოლოდ ფაილი}other{მხოლოდ ფაილები}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ამ აპს არ აქვს მინიჭებული ჩაწერის ნებართვა, მაგრამ შეუძლია ჩაიწეროს აუდიო ამ USB მოწყობილობის მეშვეობით."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"პირადი"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"სამსახური"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"კერძო"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"პირადი ხედი"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"სამსახურის ხედი"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"პირადი სივრცე"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"დაბლოკილია თქვენი IT-ადმინისტრატორის მიერ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ამ კონტენტის სამსახურის აპებისთვის გაზიარება შეუძლებელია"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ამ კონტენტის სამსახურის აპებით გახსნა შეუძლებელია"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"სამსახურის აპები დაპაუზებულია"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"პაუზის გაუქმება"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"სამსახურის აპები არ არის"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"პირადი აპები არ არის"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"არსებული პირადი აპებით მხარდაჭერილი არ არის"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს პირად პროფილში?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს სამსახურის პროფილში?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"პირადი ბრაუზერის გამოყენება"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ტექსტის ჩასმა"</string>
<string name="exclude_link" msgid="1332778255031992228">"ბმულის ამოღება"</string>
<string name="include_link" msgid="827855767220339802">"ბმულის დართვა"</string>
+ <string name="pinned" msgid="7623664001331394139">"ჩამაგრებული"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"არჩევადი სურათი"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"არჩევადი ვიდეო"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"არჩევადი ერთეული"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ღილაკი"</string>
</resources>
diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml
index add2fd0a..99357ef6 100644
--- a/java/res/values-kk/strings.xml
+++ b/java/res/values-kk/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Сурет бөлісіп жатырсыз}other{# сурет бөлісіп жатырсыз}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Бейне бөлісіліп жатыр}other{# бейне бөлісіліп жатыр}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# файлды бөлісіп жатыр}other{# файлды бөлісіп жатыр}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Бөлісетін элементтерді таңдау"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Мәтіні бар сурет жіберу}other{Мәтіні бар # сурет жіберу}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Сілтемесі бар сурет жіберу}other{Сілтемесі бар # сурет жіберу}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Мәтіні бар бейне жіберу}other{Мәтіні бар # бейне жіберу}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Сілтемесі бар бейне жіберу}other{Сілтемесі бар # бейне жіберу}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Мәтіні бар файл жіберу}other{Мәтіні бар # файл жіберу}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Сілтемесі бар файл жіберу}other{Сілтемесі бар # файл жіберу}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Альбомды бөлісу"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Тек сурет}other{Тек суреттер}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Тек бейне}other{Тек бейнелер}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Тек файл}other{Тек файлдар}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Қолданбаға жазу рұқсаты берілмеді, бірақ ол осы USB құрылғысы арқылы дыбыс жаза алады."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Жұмыс"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Құпия"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көру"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жұмыс деректерін көру"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Құпия көрініс"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Әкімшіңіз бөгеген"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бұл контентті жұмыс қолданбаларымен бөлісу мүмкін емес."</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бұл контентті жұмыс қолданбаларымен ашу мүмкін емес."</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жұмыс қолданбалары кідіртілген."</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Қайта қосу"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жұмыс қолданбалары жоқ."</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке қолданбалар жоқ."</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке қолданбалар жоқ."</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> қолданбасын жеке профиліңізде ашу керек пе?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> қолданбасын жұмыс профиліңізде ашу керек пе?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке браузерді пайдалану"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Мәтін қосу"</string>
<string name="exclude_link" msgid="1332778255031992228">"Сілтемені шығару"</string>
<string name="include_link" msgid="827855767220339802">"Сілтеме қосу"</string>
+ <string name="pinned" msgid="7623664001331394139">"Бекітілген"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Таңдауға болатын сурет"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Таңдауға болатын бейне"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Таңдауға болатын элемент"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Түйме"</string>
</resources>
diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml
index 39a2048b..29d80e96 100644
--- a/java/res/values-km/strings.xml
+++ b/java/res/values-km/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{កំពុងចែក​រំលែករូបភាព}other{កំពុងចែក​រំលែករូបភាព #}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{កំពុងចែករំលែកវីដេអូ}other{កំពុងចែករំលែកវីដេអូ #}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{កំពុង​ចែករំលែកឯកសារ #}other{កំពុង​ចែករំលែកឯកសារ #}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ជ្រើសរើសធាតុដែលត្រូវចែករំលែក"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ចែករំលែករូបភាពជាមួយអក្សរ}other{ចែករំលែករូបភាព # ជាមួយអក្សរ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ចែករំលែករូបភាពជាមួយតំណ}other{ចែករំលែករូបភាព # ជាមួយតំណ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ចែករំលែកវីដេអូជាមួយអក្សរ}other{ចែករំលែក # វីដេអូជាមួយអក្សរ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ចែករំលែកវីដេអូជាមួយតំណ}other{ចែករំលែក # វីដេអូជាមួយតំណ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ចែករំលែកឯកសារជាមួយអក្សរ}other{ចែករំលែក # ឯកសារជាមួយអក្សរ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ចែករំលែកឯកសារជាមួយតំណ}other{ចែករំលែកឯកសារ # ជាមួយតំណ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"កំពុងចែករំលែកអាល់ប៊ុម"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{រូបភាព​តែប៉ុណ្ណោះ}other{រូបភាពតែប៉ុណ្ណោះ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{​វីដេអូតែប៉ុណ្ណោះ}other{វីដេអូតែប៉ុណ្ណោះ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ឯកសារតែប៉ុណ្ណោះ}other{ឯកសារតែប៉ុណ្ណោះ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"កម្មវិធីនេះ​មិនទាន់បាន​ទទួលសិទ្ធិ​ថតសំឡេង​នៅឡើយទេ ប៉ុន្តែអាច​ថតសំឡេង​តាមរយៈ​ឧបករណ៍ USB នេះបាន។"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ផ្ទាល់ខ្លួន"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ការងារ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ឯកជន"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ទិដ្ឋភាពផ្ទាល់ខ្លួន"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ទិដ្ឋភាព​ការងារ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ទិដ្ឋភាពឯកជន"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"បានទប់ស្កាត់ដោយ​អ្នកគ្រប់គ្រង​ផ្នែកព័ត៌មានវិទ្យា​របស់អ្នក"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ខ្លឹមសារនេះ​មិនអាចចែករំលែក​តាមរយៈ​កម្មវិធី​ការងារ​បានទេ"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ខ្លឹមសារនេះ​មិនអាចបើក​តាមរយៈ​កម្មវិធី​ការងារ​បានទេ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"មិនអាចចែករំលែកខ្លឹមសារនេះ​ជាមួយ​កម្មវិធី​ផ្ទាល់ខ្លួន​បានទេ"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ខ្លឹមសារនេះ​មិនអាចបើក​តាមរយៈ​កម្មវិធី​ផ្ទាល់ខ្លួន​បានទេ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"មិនអាចចែករំលែកខ្លឹមសារនេះជាមួយកម្មវិធីឯកជនបានទេ"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"មិនអាចបើកខ្លឹមសារនេះដោយប្រើកម្មវិធីឯកជនបានទេ"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"កម្មវិធី​ការងារ​ត្រូវបានផ្អាក"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ឈប់ផ្អាក"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"គ្មាន​កម្មវិធី​ការងារ​ទេ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"គ្មាន​កម្មវិធី​ផ្ទាល់ខ្លួន​ទេ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"គ្មានកម្មវិធីឯកជនទេ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រង​ព័ត៌មាន​ផ្ទាល់​ខ្លួនរបស់អ្នកឬ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រងព័ត៌មានការងាររបស់អ្នកឬ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ប្រើ​កម្មវិធីរុករក​តាមអ៊ីនធឺណិត​ផ្ទាល់ខ្លួន"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"រួមបញ្ចូលអក្សរ"</string>
<string name="exclude_link" msgid="1332778255031992228">"មិនរួមបញ្ចូលតំណ"</string>
<string name="include_link" msgid="827855767220339802">"រួមបញ្ចូល​តំណ"</string>
+ <string name="pinned" msgid="7623664001331394139">"បាន​ខ្ទាស់"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"រូបភាពដែល​អាចជ្រើសរើសបាន"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"វីដេអូដែល​អាចជ្រើសរើសបាន"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ធាតុដែល​អាចជ្រើសរើសបាន"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ប៊ូតុង"</string>
</resources>
diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml
index cb27a7af..d777b6fa 100644
--- a/java/res/values-kn/strings.xml
+++ b/java/res/values-kn/strings.xml
@@ -45,8 +45,8 @@
<string name="use_a_different_app" msgid="2062380818535918975">"ಬೇರೊಂದು ಆ್ಯಪ್ ಬಳಸಿ"</string>
<string name="chooseActivity" msgid="6659724877523973446">"ಕ್ರಿಯೆಯನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string>
<string name="noApplications" msgid="1139487441772284671">"ಯಾವುದೇ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು ಈ ಕ್ರಿಯೆಗಾಗಿ ಬದ್ಧತೆ ತೋರಿಸುವುದಿಲ್ಲ."</string>
- <string name="forward_intent_to_owner" msgid="6454987608971162379">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್‌ನ ಹೊರಗೆ ನೀವು ಈ ಅಪ್ಲಿಕೇಶನ್‌ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string>
- <string name="forward_intent_to_work" msgid="2906094223089139419">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್‌ನಲ್ಲಿ ನೀವು ಈ ಅಪ್ಲಿಕೇಶನ್‌ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string>
+ <string name="forward_intent_to_owner" msgid="6454987608971162379">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್‌ನ ಹೊರಗೆ ನೀವು ಈ ಆ್ಯಪ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string>
+ <string name="forward_intent_to_work" msgid="2906094223089139419">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್‌ನಲ್ಲಿ ನೀವು ಈ ಆ್ಯಪ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string>
<string name="activity_resolver_use_always" msgid="8674194687637555245">"ಯಾವಾಗಲೂ"</string>
<string name="activity_resolver_use_once" msgid="594173435998892989">"ಒಮ್ಮೆ ಮಾತ್ರ"</string>
<string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"<xliff:g id="APP">%1$s</xliff:g> ಉದ್ಯೋಗದ ಪ್ರೊಫೈಲ್ ಅನ್ನು ಬೆಂಬಲಿಸುವುದಿಲ್ಲ"</string>
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # ಫೈಲ್‌}one{+ # ಫೈಲ್‌ಗಳು}other{+ # ಫೈಲ್‌ಗಳು}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # ಇನ್ನಷ್ಟು ಫೈಲ್}one{+ # ಇನ್ನಷ್ಟು ಫೈಲ್‌ಗಳು}other{+ # ಇನ್ನಷ್ಟು ಫೈಲ್‌ಗಳು}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"ಪಠ್ಯ ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ"</string>
- <string name="sharing_link" msgid="2307694372813942916">"ಲಿಂಕ್ ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"ಹಂಚಿಕೊಳ್ಳಬಹುದಾದ ಲಿಂಕ್"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ಹಂಚಿಕೊಳ್ಳಲು ಐಟಂಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ಲಿಂಕ್‌ನೊಂದಿಗೆ ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ಲಿಂಕ್‌ನೊಂದಿಗೆ ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ಲಿಂಕ್‌ನೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್‌ನೊಂದಿಗೆ # ಫೈಲ್‌ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ಆಲ್ಬಮ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ಚಿತ್ರ ಮಾತ್ರ}one{ಚಿತ್ರಗಳು ಮಾತ್ರ}other{ಚಿತ್ರಗಳು ಮಾತ್ರ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ವೀಡಿಯೊ ಮಾತ್ರ}one{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}other{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ಫೈಲ್ ಮಾತ್ರ}one{ಫೈಲ್‌ಗಳು ಮಾತ್ರ}other{ಫೈಲ್‌ಗಳು ಮಾತ್ರ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ಈ ಆ್ಯಪ್‌ಗೆ ರೆಕಾರ್ಡ್ ಅನುಮತಿಯನ್ನು ನೀಡಲಾಗಿಲ್ಲ, ಆದರೆ ಈ USB ಸಾಧನದ ಮೂಲಕ ಆಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಬಲ್ಲದು."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ವೈಯಕ್ತಿಕ"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ಕೆಲಸ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ಖಾಸಗಿ"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ವೈಯಕ್ತಿಕ ವೀಕ್ಷಣೆ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ಕೆಲಸದ ವೀಕ್ಷಣೆ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ಖಾಸಗಿ ವೀಕ್ಷಣೆ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ನಿಮ್ಮ IT ನಿರ್ವಾಹಕರಿಂದ ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್‌ಗಳ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್‌ಗಳ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್‌ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್‌ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ಈ ಕಂಟೆಂಟ್‌ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್‌ಗಳ ಜೊತೆಗೆ ಹಂಚಿಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ಈ ಕಂಟೆಂಟ್‌ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್‌ಗಳ ಮೂಲಕ ತೆರೆಯಲು ಸಾಧ್ಯವಿಲ್ಲ"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್‌ಗಳನ್ನು ವಿರಾಮಗೊಳಿಸಲಾಗಿದೆ"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ವಿರಾಮವನ್ನು ರದ್ದುಗೊಳಿಸಿ"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ಯಾವುದೇ ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್‌ಗಳಿಲ್ಲ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ಯಾವುದೇ ವೈಯಕ್ತಿಕ ಆ್ಯಪ್‌ಗಳಿಲ್ಲ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ಯಾವುದೇ ಖಾಸಗಿ ಆ್ಯಪ್‍‍ಗಳಿಲ್ಲ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ನಿಮ್ಮ ವೈಯಕ್ತಿಕ ಪ್ರೊಫೈಲ್‌ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"ನಿಮ್ಮ ಉದ್ಯೋಗದ ಪ್ರೊಫೈಲ್‌ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ವೈಯಕ್ತಿಕ ಬ್ರೌಸರ್ ಬಳಸಿ"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ಪಠ್ಯವನ್ನು ಸೇರಿಸಿ"</string>
<string name="exclude_link" msgid="1332778255031992228">"ಲಿಂಕ್ ಹೊರತುಪಡಿಸಿ"</string>
<string name="include_link" msgid="827855767220339802">"ಲಿಂಕ್ ಸೇರಿಸಿ"</string>
+ <string name="pinned" msgid="7623664001331394139">"ಪಿನ್‌ ಮಾಡಲಾಗಿದೆ"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ಆಯ್ಕೆಮಾಡಬಹುದಾದ ಚಿತ್ರ"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ಆಯ್ಕೆ ಮಾಡಬಹುದಾದ ವೀಡಿಯೊ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ಆಯ್ಕೆ ಮಾಡಬಹುದಾದ ಐಟಂ"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ಬಟನ್"</string>
</resources>
diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml
index de8de12a..0ab0cefb 100644
--- a/java/res/values-ko/strings.xml
+++ b/java/res/values-ko/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{이미지 공유}other{이미지 #개 공유}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{동영상 1개 공유 중}other{동영상 #개 공유 중}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{파일 #개 공유 중}other{파일 #개 공유 중}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"공유할 항목 선택"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{텍스트로 이미지 공유 중}other{텍스트로 이미지 #개 공유 중}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{링크로 이미지 공유 중}other{링크로 이미지 #개 공유 중}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{텍스트로 동영상 공유 중}other{텍스트로 동영상 #개 공유 중}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{링크로 동영상 공유 중}other{링크로 동영상 #개 공유 중}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{텍스트로 파일 공유 중}other{텍스트로 파일 #개 공유 중}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{링크로 파일 공유 중}other{링크로 파일 #개 공유 중}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"앨범 공유"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{이미지만}other{이미지만}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{동영상만}other{동영상만}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{파일만}other{파일만}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"이 앱에는 녹음 권한이 부여되지 않았지만, 이 USB 기기를 통해 오디오를 녹음할 수 있습니다."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"개인"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"직장"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"비공개"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"개인 뷰"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"직장 뷰"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"비공개 뷰"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 관리자에 의해 차단됨"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"이 콘텐츠는 직장 앱을 통해 공유할 수 없습니다."</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"이 콘텐츠는 직장 앱으로 열 수 없습니다."</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"이 콘텐츠는 개인 앱을 통해 공유할 수 없습니다."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"이 콘텐츠는 개인 앱으로 열 수 없습니다."</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"이 콘텐츠는 비공개 앱에 공유할 수 없습니다."</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"이 콘텐츠는 비공개 앱으로 열 수 없습니다."</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"직장 앱이 일시중지됨"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"일시중지 해제"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"직장 앱 없음"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"개인 앱 없음"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"비공개 앱을 사용할 수 없습니다."</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"개인 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"직장 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"개인 브라우저 사용"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"텍스트 포함"</string>
<string name="exclude_link" msgid="1332778255031992228">"링크 제외"</string>
<string name="include_link" msgid="827855767220339802">"링크 포함"</string>
+ <string name="pinned" msgid="7623664001331394139">"고정됨"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"선택 가능한 이미지"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"선택 가능한 동영상"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"선택 가능한 항목"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"버튼"</string>
</resources>
diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml
index 96ffb0ce..7de1593d 100644
--- a/java/res/values-ky/strings.xml
+++ b/java/res/values-ky/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Сүрөт бөлүшүү}other{# сүрөт бөлүшүлүүдө}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Видео бөлүшүлүүдө}other{# видео бөлүшүлүүдө}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# файл бөлүшүлүүдө}other{# файл бөлүшүлүүдө}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Бөлүшө турган нерселерди тандаңыз"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Сүрөттү текст менен жөнөтүү}other{# cүрөттү текст менен жөнөтүү}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Сүрөттү шилтеме менен жөнөтүү}other{# сүрөттү шилтеме менен жөнөтүү}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Видеону текст менен жөнөтүү}other{# видеону текст менен жөнөтүү}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Видеону шилтеме менен жөнөтүү}other{# видеону шилтеме менен жөнөтүү}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Файлды текст менен жөнөтүү}other{# файлды текст менен жөнөтүү}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Файлды шилтеме менен жөнөтүү}other{# файлды шилтеме менен жөнөтүү}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Альбом бөлүшүлүүдө"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Сүрөт гана}other{Сүрөттөр гана}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Видео гана}other{Видеолор гана}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Файл гана}other{Файлдар гана}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Бул колдонмого жаздырууга уруксат берилген эмес, бирок ушул USB түзмөгү аркылуу үндөрдү жаза алат."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Жумуш"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Купуя"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көрүнүш"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жумуш көрүнүшү"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Купуя көрүнүш"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT администраторуңуз бөгөттөп койгон"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бул нерсени жумуш колдонмолору менен бөлүшө албайсыз"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бул нерсени жумуш колдонмолору менен ача албайсыз"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бул нерсени жеке колдонмолор менен бөлүшө албайсыз"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бул нерсени жеке колдонмолор менен ача албайсыз"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бул мазмун жеке колдонмолор менен бөлүшүлбөйт"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бул мазмун жеке колдонмолор менен ачылбайт"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жумуш колдонмолору тындырылды"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Иштетүү"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жумуш колдонмолору жок"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке колдонмолор жок"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке колдонмолор жок"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> колдонмосу жеке профилде ачылсынбы?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> колдонмосу жумуш профилинде ачылсынбы?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке серепчини колдонуу"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Текст кошуу"</string>
<string name="exclude_link" msgid="1332778255031992228">"Шилтемени чыгарып салуу"</string>
<string name="include_link" msgid="827855767220339802">"Шилтеме кошуу"</string>
+ <string name="pinned" msgid="7623664001331394139">"Кадалган"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Тандала турган сүрөт"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Тандала турган видео"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Тандала турган нерсе"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Баскыч"</string>
</resources>
diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml
index 1b9996ac..9481a9ae 100644
--- a/java/res/values-lo/strings.xml
+++ b/java/res/values-lo/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"ບັນທຶກຮູບພາບດ້ວຍ"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"ບັນທຶກຮູບພາບດ້ວຍ <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"ບັນທຶກຮູບພາບ"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"ນຳໃຊ້ແອັບຯອື່ນ"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"ໃຊ້ແອັບອື່ນ"</string>
<string name="chooseActivity" msgid="6659724877523973446">"ເລືອກຄຳສັ່ງ"</string>
<string name="noApplications" msgid="1139487441772284671">"ບໍ່ມີແອັບຯໃດສາມາດເຮັດວຽກນີ້ໄດ້."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"​ທ່ານ​ກຳ​ລັງ​ໃຊ້​ແອັບຯ​ນີ້ນອກ​ໂປຣ​ໄຟລ໌​ບ່ອນ​ເຮັດ​ວຽກ​ຂອງ​ທ່ານ"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ກຳລັງແບ່ງປັນຮູບ}other{ກຳລັງແບ່ງປັນ # ຮູບ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{ກຳລັງຈະແບ່ງປັນ # ໄຟລ໌}other{ກຳລັງຈະແບ່ງປັນ # ໄຟລ໌}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ເລືອກລາຍການທີ່ຈະແບ່ງປັນ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ກຳລັງແບ່ງປັນຮູບພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ຮູບພ້ອມຂໍ້ຄວາມ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ກຳລັງແບ່ງປັນຮູບພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ຮູບພ້ອມລິ້ງ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອພ້ອມຂໍ້ຄວາມ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອພ້ອມລິ້ງ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມຂໍ້ຄວາມ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມລິ້ງ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ກຳລັງແບ່ງປັນອະລະບ້ຳ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ຮູບເທົ່ານັ້ນ}other{ຮູບເທົ່ານັ້ນ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ວິດີໂອເທົ່ານັ້ນ}other{ວິດີໂອເທົ່ານັ້ນ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ໄຟລ໌ເທົ່ານັ້ນ}other{ໄຟລ໌ເທົ່ານັ້ນ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ແອັບນີ້ບໍ່ໄດ້ຮັບສິດອະນຸຍາດໃນການບັນທຶກ ແຕ່ສາມາດບັນທຶກສຽງໄດ້ຜ່ານອຸປະກອນ USB ນີ້."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ສ່ວນຕົວ"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ວຽກ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ສ່ວນຕົວ"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ມຸມມອງສ່ວນຕົວ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ມຸມມອງວຽກ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ມຸມມອງສ່ວນຕົວ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ຖືກບລັອກໄວ້ໂດຍຜູ້ເບິ່ງແຍງໄອທີຂອງທ່ານ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບບ່ອນເຮັດວຽກໄດ້"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບບ່ອນເຮັດວຽກ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບສ່ວນຕົວໄດ້"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບສ່ວນຕົວ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ບໍ່ສາມາດແບ່ງປັນເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ບໍ່ສາມາດເປີດເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ຢຸດແອັບບ່ອນເຮັດວຽກໄວ້ຊົ່ວຄາວແລ້ວ"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ຍົກເລີກການຢຸດຊົ່ວຄາວ"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ບໍ່ມີແອັບບ່ອນເຮັດວຽກ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ບໍ່ມີແອັບສ່ວນຕົວ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ບໍ່ມີແອັບສ່ວນຕົວ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນໂປຣໄຟລ໌ສ່ວນຕົວຂອງທ່ານບໍ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນ​ໂປຣ​ໄຟລ໌​ບ່ອນ​ເຮັດ​ວຽກຂອງທ່ານບໍ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ໃຊ້ໂປຣແກຣມທ່ອງເວັບສ່ວນຕົວ"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ຮວມຂໍ້ຄວາມ"</string>
<string name="exclude_link" msgid="1332778255031992228">"ບໍ່ຮວມລິ້ງ"</string>
<string name="include_link" msgid="827855767220339802">"ຮວມລິ້ງ"</string>
+ <string name="pinned" msgid="7623664001331394139">"ປັກໝຸດແລ້ວ"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ຮູບທີ່ເລືອກໄດ້"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ວິດີໂອທີ່ເລືອກໄດ້"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ລາຍການທີ່ເລືອກໄດ້"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ປຸ່ມ"</string>
</resources>
diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml
index 1d7fc237..f1a0494d 100644
--- a/java/res/values-lt/strings.xml
+++ b/java/res/values-lt/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Bendrinamas vaizdas}one{Bendrinamas # vaizdas}few{Bendrinami # vaizdai}many{Bendrinama # vaizdo}other{Bendrinama # vaizdų}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bendrinamas vaizdo įrašas}one{Bendrinamas # vaizdo įrašas}few{Bendrinami # vaizdo įrašai}many{Bendrinama # vaizdo įrašo}other{Bendrinama # vaizdo įrašų}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Bendrinamas # failas}one{Bendrinamas # failas}few{Bendrinami # failai}many{Bendrinama # failo}other{Bendrinama # failų}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Norimų bendrinti elementų pasirinkimas"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Bendrinamas vaizdas su tekstu}one{Bendrinamas # vaizdas su tekstu}few{Bendrinami # vaizdai su tekstu}many{Bendrinama # vaizdo su tekstu}other{Bendrinama # vaizdų su tekstu}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bendrinamas vaizdas su nuoroda}one{Bendrinamas # vaizdas su nuoroda}few{Bendrinami # vaizdai su nuoroda}many{Bendrinama # vaizdo su nuoroda}other{Bendrinama # vaizdų su nuoroda}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Bendrinamas vaizdo įrašas su tekstu}one{Bendrinamas # vaizdo įrašas su tekstu}few{Bendrinami # vaizdo įrašai su tekstu}many{Bendrinama # vaizdo įrašo su tekstu}other{Bendrinama # vaizdo įrašų su tekstu}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bendrinamas vaizdo įrašas su nuoroda}one{Bendrinamas # vaizdo įrašas su nuoroda}few{Bendrinami # vaizdo įrašai su nuoroda}many{Bendrinamas # vaizdo įrašo su nuoroda}other{Bendrinama # vaizdo įrašų su nuoroda}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bendrinamas failas su tekstu}one{Bendrinamas # failas su tekstu}few{Bendrinami # failai su tekstu}many{Bendrinama # failo su tekstu}other{Bendrinama # failų su tekstu}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bendrinamas failas su nuoroda}one{Bendrinamas # failas su nuoroda}few{Bendrinami # failai su nuoroda}many{Bendrinama # failo su nuoroda}other{Bendrinama # failų su nuoroda}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Bendrinamas albumas"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tik vaizdas}one{Tik vaizdai}few{Tik vaizdai}many{Tik vaizdai}other{Tik vaizdai}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tik vaizdo įrašas}one{Tik vaizdo įrašai}few{Tik vaizdo įrašai}many{Tik vaizdo įrašai}other{Tik vaizdo įrašai}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tik failas}one{Tik failai}few{Tik failai}many{Tik failai}other{Tik failai}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šiai programai nebuvo suteiktas leidimas įrašyti, bet ji gali užfiksuoti garsą per šį USB įrenginį."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Asmeninis"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Darbo"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privatus"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Asmeninė peržiūra"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darbo peržiūra"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatus rodinys"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Užblokavo jūsų IT administratorius"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šio turinio negalima bendrinti su darbo programomis"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šio turinio negalima atidaryti naudojant darbo programas"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šio turinio negalima bendrinti su asmeninėmis programomis"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šio turinio negalima atidaryti naudojant asmenines programas"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šio turinio negalima bendrinti su privačiomis programomis"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šio turinio negalima atidaryti naudojant privačias programas"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darbo programos pristabdytos"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Atšaukti pristabdymą"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nėra darbo programų"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nėra asmeninių programų"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nėra privačių programų"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ asmeniniame profilyje?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ darbo profilyje?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Naudoti asmeninę naršyklę"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Įtraukti tekstą"</string>
<string name="exclude_link" msgid="1332778255031992228">"Išskirti nuorodą"</string>
<string name="include_link" msgid="827855767220339802">"Įtraukti nuorodą"</string>
+ <string name="pinned" msgid="7623664001331394139">"Prisegta"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Pasirenkamas vaizdas"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Pasirenkamas vaizdo įrašas"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Pasirenkamas elementas"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Mygtukas"</string>
</resources>
diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml
index 7800e6d6..5fed4d43 100644
--- a/java/res/values-lv/strings.xml
+++ b/java/res/values-lv/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{un vēl # fails}zero{un vēl # faili}one{un vēl # fails}other{un vēl # faili}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Un vēl # fails}zero{Un vēl # failu}one{Un vēl # fails}other{Un vēl # faili}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Tiek kopīgots teksts"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Tiek kopīgota saite"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Kopīgošanas saite"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Tiek kopīgots attēls}zero{Tiek kopīgoti # attēli}one{Tiek kopīgots # attēls}other{Tiek kopīgoti # attēli}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Tiek kopīgots video}zero{Tiek kopīgoti # video}one{Tiek kopīgots # video}other{Tiek kopīgoti # video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Notiek # faila kopīgošana}zero{Notiek # failu kopīgošana}one{Notiek # faila kopīgošana}other{Notiek # failu kopīgošana}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Atlasiet kopīgojamos vienumus"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Tiek kopīgots attēls ar tekstu}zero{Tiek kopīgoti # attēli ar tekstu}one{Tiek kopīgots # attēls ar tekstu}other{Tiek kopīgoti # attēli ar tekstu}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Tiek kopīgots attēls ar saiti}zero{Tiek kopīgoti # attēli ar saitēm}one{Tiek kopīgots # attēls ar saitēm}other{Tiek kopīgoti # attēli ar saitēm}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Tiek kopīgots videoklips ar tekstu}zero{Tiek kopīgoti # videoklipi ar tekstu}one{Tiek kopīgots # videoklips ar tekstu}other{Tiek kopīgoti # videoklipi ar tekstu}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Tiek kopīgots videoklips ar saiti}zero{Tiek kopīgoti # videoklipi ar saitēm}one{Tiek kopīgots # videoklips ar saitēm}other{Tiek kopīgoti # videoklipi ar saitēm}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiek kopīgots fails ar tekstu}zero{Tiek kopīgoti # faili ar tekstu}one{Tiek kopīgots # fails ar tekstu}other{Tiek kopīgoti # faili ar tekstu}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiek kopīgots fails ar saiti}zero{Tiek kopīgoti # faili ar saitēm}one{Tiek kopīgots # fails ar saitēm}other{Tiek kopīgoti # faili ar saitēm}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Notiek albuma kopīgošana"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tikai attēls}zero{Tikai attēli}one{Tikai attēli}other{Tikai attēli}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tikai videoklips}zero{Tikai videoklipi}one{Tikai videoklipi}other{Tikai videoklipi}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tikai fails}zero{Tikai faili}one{Tikai faili}other{Tikai faili}}"</string>
@@ -74,19 +76,24 @@
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Faila priekšskatījuma sīktēls"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nav ieteikta neviena persona, ar ko kopīgot"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šai lietotnei nav piešķirta ierakstīšanas atļauja, taču tā varētu tvert audio, izmantojot šo USB ierīci."</string>
- <string name="resolver_personal_tab" msgid="1381052735324320565">"Privātais profils"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"Personīgais profils"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Darba profils"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privāts"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personisks skats"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darba skats"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privātais skats"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloķējis jūsu IT administrators"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šo saturu nevar kopīgot ar darba lietotnēm"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šo saturu nevar atvērt darba lietotnēs"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šo saturu nevar kopīgot ar personīgajām lietotnēm"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šo saturu nevar atvērt personīgajās lietotnēs"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šo saturu nevar kopīgot ar privātajām lietotnēm"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šo saturu nevar atvērt privātajās lietotnēs"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darba lietotnes ir apturētas."</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Aktivizēt"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nav darba lietotņu"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nav personīgu lietotņu"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nav privātu lietotņu"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu personīgajā profilā?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu darba profilā?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Izmantot personīgo pārlūku"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Iekļaut tekstu"</string>
<string name="exclude_link" msgid="1332778255031992228">"Izslēgt saiti"</string>
<string name="include_link" msgid="827855767220339802">"Iekļaut saiti"</string>
+ <string name="pinned" msgid="7623664001331394139">"Piespraustās"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Atlasāms attēls"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Atlasāms video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Atlasāms vienums"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Poga"</string>
</resources>
diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml
index 248fc882..2ab3c072 100644
--- a/java/res/values-mk/strings.xml
+++ b/java/res/values-mk/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"Сними слика со"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"Снимање слика со <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"Сними слика"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"Користи различна апликација"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"Употреби друга апликација"</string>
<string name="chooseActivity" msgid="6659724877523973446">"Избирање дејство"</string>
<string name="noApplications" msgid="1139487441772284671">"Нема апликации што можат да го извршат ова дејство."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"Ја користите апликацијата надвор од работниот профил"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Споделување слика}one{Споделување # слика}other{Споделување # слики}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Се споделува видео}one{Се споделува # видео}other{Се споделуваат # видеа}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Се споделува # датотека}one{Се споделуваат # датотека}other{Се споделуваат # датотеки}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Изберете ставки за споделување"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Се споделува слика со SMS}one{Се споделуваат # слика со SMS}other{Се споделуваат # слики со SMS}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Се споделува слика со линк}one{Се споделуваат # слика со линк}other{Се споделуваат # слики со линк}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Се споделува видео со SMS}one{Се споделуваат # видео со SMS}other{Се споделуваат # видеа со SMS}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Се споделува видео со линк}one{Се споделуваат # видео со линк}other{Се споделуваat # видеa со линк}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Се споделува датотека со SMS}one{Се споделуваат # датотека со SMS}other{Се споделуваат # датотеки со SMS}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Се споделува датотека со линк}one{Се споделуваат # датотека со линк}other{Се споделуваат # датотеки со линк}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Споделување албум"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слики}other{Само слики}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видеа}other{Само видеа}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само датотека}one{Само датотеки}other{Само датотеки}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"На апликацијава не ѝ е доделена дозвола за снимање, но може да снима аудио преку овој USB-уред."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"За работа"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен приказ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Работен приказ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватен приказ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано од IT-администраторот"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овие содржини не може да се споделуваат со работни апликации"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овие содржини не може да се отвораат со работни апликации"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овие содржини не може да се споделуваат со лични апликации"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овие содржини не може да се отвораат со лични апликации"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овие содржини не може да се споделуваат со приватни апликации"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овие содржини не може да се отвораат со лични апликации"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Работните апликации се паузирани"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Прекини ја паузата"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема работни апликации"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема лични апликации"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Нема приватни апликации"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Да се отвори <xliff:g id="APP">%s</xliff:g> во личниот профил?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Да се отвори <xliff:g id="APP">%s</xliff:g> во работниот профил?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи личен прелистувач"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Опфати текст"</string>
<string name="exclude_link" msgid="1332778255031992228">"Исклучи линк"</string>
<string name="include_link" msgid="827855767220339802">"Вклучи линк"</string>
+ <string name="pinned" msgid="7623664001331394139">"Закачено"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Слика што може да се избере"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Видео што може да се избере"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Ставка што може да се избере"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Копче"</string>
</resources>
diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml
index fb39b79c..6318a101 100644
--- a/java/res/values-ml/strings.xml
+++ b/java/res/values-ml/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"ഇനിപ്പറയുന്നതിൽ ചിത്രം എടുക്കുക:"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> ഉപയോഗിച്ച് ചിത്രം എടുക്കുക"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"ചിത്രം എടുക്കുക"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"മറ്റൊരു അപ്ലിക്കേഷൻ ഉപയോഗിക്കുക"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"മറ്റൊരു ആപ്പ് ഉപയോഗിക്കുക"</string>
<string name="chooseActivity" msgid="6659724877523973446">"ഒരു പ്രവർത്തനം തിരഞ്ഞെടുക്കുക"</string>
<string name="noApplications" msgid="1139487441772284671">"അപ്ലിക്കേഷനുകൾക്കൊന്നും ഈ പ്രവർത്തനം നിർവഹിക്കാനാവില്ല."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"നിങ്ങളുടെ ഔദ്യോഗിക പ്രൊഫൈലിന് പുറത്ത് ഈ അപ്ലിക്കേഷൻ ഉപയോഗിക്കുന്നു"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ചിത്രം പങ്കിടുന്നു}other{# ചിത്രങ്ങൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{വീഡിയോ പങ്കിടുന്നു}other{# വീഡിയോകൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ഫയൽ പങ്കിടുന്നു}other{# ഫയലുകൾ പങ്കിടുന്നു}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"പങ്കിടാൻ ഇനങ്ങൾ തിരഞ്ഞെടുക്കുക"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം ചിത്രം പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # ചിത്രം പങ്കിടുന്നു}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ലിങ്കിനൊപ്പം ചിത്രം പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # ചിത്രങ്ങൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം വീഡിയോ പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # വീഡിയോകൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ലിങ്കിനൊപ്പം വീഡിയോ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # വീഡിയോകൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ലിങ്കിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ആൽബം പങ്കിടൽ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ചിത്രം മാത്രം}other{ചിത്രങ്ങൾ മാത്രം}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{വീഡിയോ മാത്രം}other{വീഡിയോകൾ മാത്രം}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ഫയൽ മാത്രം}other{ഫയലുകൾ മാത്രം}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ഈ ആപ്പിന് റെക്കോർഡ് അനുമതി നൽകിയിട്ടില്ല, എന്നാൽ ഈ USB ഉപകരണത്തിലൂടെ ഓഡിയോ ക്യാപ്‌ചർ ചെയ്യാനാവും."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"വ്യക്തിപരം"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ഔദ്യോഗികം"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"സ്വകാര്യം"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"വ്യക്തിപര കാഴ്‌ച"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ഔദ്യോഗിക കാഴ്‌ച"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"സ്വകാര്യ കാഴ്ച"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"നിങ്ങളുടെ ഐടി അഡ്‌മിൻ ബ്ലോക്ക് ചെയ്‌തു"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ഔദ്യോഗിക ആപ്പുകൾ തൽക്കാലം നിർത്തിയിരിക്കുന്നു"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"താൽക്കാലികമായി നിർത്തിയത് മാറ്റുക"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ഔദ്യോഗിക ആപ്പുകൾ ഇല്ല"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"വ്യക്തിപര ആപ്പുകൾ ഇല്ല"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"സ്വകാര്യ ആപ്പുകൾ ഇല്ല"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ വ്യക്തിപരമായ പ്രൊഫൈലിൽ തുറക്കണോ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ ഔദ്യോഗിക പ്രൊഫൈലിൽ തുറക്കണോ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"വ്യക്തിപരമായ ബ്രൗസർ ഉപയോഗിക്കുക"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ടെക്‌സ്റ്റ് ഉൾപ്പെടുത്തുക"</string>
<string name="exclude_link" msgid="1332778255031992228">"ലിങ്ക് ഒഴിവാക്കുക"</string>
<string name="include_link" msgid="827855767220339802">"ലിങ്ക് ഉൾപ്പെടുത്തുക"</string>
+ <string name="pinned" msgid="7623664001331394139">"പിൻ ചെയ്‌തത്"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"തിരഞ്ഞെടുക്കാവുന്ന ചിത്രം"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"തിരഞ്ഞെടുക്കാവുന്ന വീഡിയോ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"തിരഞ്ഞെടുക്കാവുന്ന ഇനം"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ബട്ടൺ"</string>
</resources>
diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml
index 9d0ea1b8..8dc3cd58 100644
--- a/java/res/values-mn/strings.xml
+++ b/java/res/values-mn/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Зураг хуваалцаж байна}other{# зураг хуваалцаж байна}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Видео хуваалцаж байна}other{# видео хуваалцаж байна}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# файл хуваалцаж байна}other{# файл хуваалцаж байна}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Хуваалцах зүйлс сонгох"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Тексттэй зураг хуваалцаж байна}other{Тексттэй # зураг хуваалцаж байна}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Холбоостой зураг хуваалцаж байна}other{Холбоостой # зураг хуваалцаж байна}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Тексттэй видео хуваалцаж байна}other{Тексттэй # видео хуваалцаж байна}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Холбоостой видео хуваалцаж байна}other{Холбоостой # видео хуваалцаж байна}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Тексттэй файл хуваалцаж байна}other{Тексттэй # файл хуваалцаж байна}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Холбоостой файл хуваалцаж байна}other{Холбоостой # файл хуваалцаж байна}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Цомог хуваалцаж байна"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Зөвхөн зураг}other{Зөвхөн зургууд}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Зөвхөн видео}other{Зөвхөн видеонууд}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Зөвхөн файл}other{Зөвхөн файлууд}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Энэ апликейшнд бичих зөвшөөрөл олгогдоогүй ч энэ USB төхөөрөмжөөр дамжуулан аудио бичиж чадсан."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Хувийн"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Ажил"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Хувийн"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Хувийн харагдах байдал"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ажлын харагдах байдал"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Хувийн харагдах байдал"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Таны IT админ блоклосон"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Энэ контентыг ажлын аппуудаар хуваалцах боломжгүй"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Энэ контентыг ажлын аппуудаар нээх боломжгүй"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ажлын аппуудыг түр зогсоосон"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Үргэлжлүүлэх"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ямар ч ажлын апп байхгүй байна"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ямар ч хувийн апп байхгүй байна"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ямар ч хувийн апп байхгүй"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Хувийн профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ажлын профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Хувийн хөтөч ашиглах"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Текстийг оруулах"</string>
<string name="exclude_link" msgid="1332778255031992228">"Холбоосыг хасах"</string>
<string name="include_link" msgid="827855767220339802">"Холбоосыг оруулах"</string>
+ <string name="pinned" msgid="7623664001331394139">"Бэхэлсэн"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Сонгох боломжтой зураг"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Сонгох боломжтой видео"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Сонгох боломжтой зүйл"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Товч"</string>
</resources>
diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml
index 0acc7472..5e54a61a 100644
--- a/java/res/values-mr/strings.xml
+++ b/java/res/values-mr/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"यासह इमेज कॅप्चर करा"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> वापरून इमेज कॅप्चर करा"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"इमेज कॅप्चर करा"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"एक भिन्न अ‍ॅप वापरा"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"वेगळे अ‍ॅप वापरा"</string>
<string name="chooseActivity" msgid="6659724877523973446">"कृती निवडा"</string>
<string name="noApplications" msgid="1139487441772284671">"कोणतेही अ‍ॅप्स ही क्रिया करू शकत नाहीत."</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"तुम्ही हा अ‍ॅप आपल्‍या कार्य प्रोफाईलच्या बाहेर वापरत आहात"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{इमेज शेअर करत आहे}other{# इमेज शेअर करत आहे}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{व्हिडिओ शेअर करत आहे}other{# व्हिडिओ शेअर करत आहे}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# फाइल शेअर करत आहे}other{# फाइल शेअर करत आहे}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"शेअर करण्यासाठी आयटम निवडा"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{मजकुरासह इमेज शेअर करत आहे}other{मजकुरासह # इमेज शेअर करत आहे}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंकसह इमेज शेअर करत आहे}other{लिंकसह # इमेज शेअर करत आहे}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{मजकुरासह व्हिडिओ शेअर करत आहे}other{मजकुरासह # व्हिडिओ शेअर करत आहे}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंकसह व्हिडिओ शेअर करत आहे}other{लिंकसह # व्हिडिओ शेअर करत आहे}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{मजकुरासह फाइल शेअर करत आहे}other{मजकुरासह # फाइल शेअर करत आहे}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंकसह फाइल शेअर करत आहे}other{लिंकसह # फाइल शेअर करत आहे}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"अल्बम शेअर करत आहे"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फक्त इमेज}other{फक्त इमेज}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{फक्त व्हिडिओ}other{फक्त व्हिडिओ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फक्त फाइल}other{फक्त फाइल}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"या अ‍ॅपला रेकॉर्ड करण्याची परवानगी दिली गेली नाही पण हे USB डिव्हाइस वापरून ऑडिओ कॅप्चर केला जाऊ शकतो."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"वैयक्तिक"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"कार्य"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"खाजगी"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"वैयक्तिक दृश्य"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"खाजगी व्ह्यू"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तुमच्या IT ॲडमिनने ब्लॉक केले"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"हा आशय कार्य ॲप्ससह शेअर केला जाऊ शकत नाही"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"हा आशय कार्य ॲप्स वापरून उघडला जाऊ शकत नाही"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"हा आशय वैयक्तिक ॲप्ससह शेअर केला जाऊ शकत नाही"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"हा आशय वैयक्तिक ॲप्स वापरून उघडला जाऊ शकत नाही"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"हा आशय खाजगी ॲप्ससोबत शेअर केला जाऊ शकत नाही"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"हा आशय खाजगी ॲप्स वापरून उघडला जाऊ शकत नाही"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामाशी संबंधित अ‍ॅप्स थांबवली आहेत"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"पुन्हा सुरू करा"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"कोणतीही कार्य ॲप्स सपोर्ट करत नाहीत"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"कोणतीही वैयक्तिक ॲप्स सपोर्ट करत नाहीत"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोणतीही खाजगी अ‍ॅप्स नाहीत"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"तुमच्या वैयक्तिक प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"तुमच्या कार्य प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"वैयक्तिक ब्राउझर वापरा"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"मजकूर समाविष्ट करा"</string>
<string name="exclude_link" msgid="1332778255031992228">"लिंक वगळा"</string>
<string name="include_link" msgid="827855767220339802">"लिंक समाविष्ट करा"</string>
+ <string name="pinned" msgid="7623664001331394139">"पिन केलेली"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"निवडण्यायोग्य इमेज"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"निवडण्यायोग्य व्हिडिओ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"निवडण्यायोग्य आयटम"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"बटण"</string>
</resources>
diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml
index dfdde1b8..b6dca50f 100644
--- a/java/res/values-ms/strings.xml
+++ b/java/res/values-ms/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Berkongsi imej}other{Berkongsi # imej}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Berkongsi video}other{Berkongsi # video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Berkongsi # fail}other{Berkongsi # fail}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Pilih item untuk dikongsi"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Berkongsi imej dengan teks}other{Berkongsi # imej dengan teks}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Berkongsi imej dengan pautan}other{Berkongsi # imej dengan pautan}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Berkongsi video dengan teks}other{Berkongsi # video dengan teks}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Berkongsi video dengan pautan}other{Berkongsi # video dengan pautan}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Berkongsi fail dengan teks}other{Berkongsi # fail dengan teks}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Berkongsi fail dengan pautan}other{Berkongsi # fail dengan pautan}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Berkongsi album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Imej sahaja}other{Imej sahaja}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video sahaja}other{Video sahaja}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fail sahaja}other{Fail sahaja}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Apl ini belum diberikan kebenaran merakam tetapi dapat merakam audio melalui peranti USB ini."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Peribadi"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Persendirian"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Paparan peribadi"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Paparan kerja"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Paparan peribadi"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Disekat oleh pentadbir IT anda"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kandungan ini tidak boleh dikongsi dengan apl kerja"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kandungan ini tidak boleh dibuka dengan apl kerja"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Apl kerja dijeda"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Nyahjeda"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tiada apl kerja"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tiada apl peribadi"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tiada apl peribadi"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil peribadi anda?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil kerja anda?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan penyemak imbas peribadi"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Sertakan teks"</string>
<string name="exclude_link" msgid="1332778255031992228">"Kecualikan pautan"</string>
<string name="include_link" msgid="827855767220339802">"Sertakan pautan"</string>
+ <string name="pinned" msgid="7623664001331394139">"Disemat"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imej yang boleh dipilih"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video yang boleh dipilih"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Item yang boleh dipilih"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Butang"</string>
</resources>
diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml
index af258250..af596656 100644
--- a/java/res/values-my/strings.xml
+++ b/java/res/values-my/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ပုံ မျှဝေနေသည်}other{ပုံ # ပုံ မျှဝေနေသည်}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ဗီဒီယို မျှဝေနေသည်}other{ဗီဒီယို # ခု မျှဝေနေသည်}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ဖိုင် မျှဝေနေသည်}other{# ဖိုင် မျှဝေနေသည်}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"မျှဝေမည့်အရာများ ရွေးခြင်း"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{စာသားပါသောပုံကို မျှဝေနေသည်}other{စာသားပါသောပုံ # ပုံကို မျှဝေနေသည်}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{လင့်ခ်ပါသောပုံကို မျှဝေနေသည်}other{လင့်ခ်ပါသောပုံ # ပုံကို မျှဝေနေသည်}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{စာသားပါသောဗီဒီယိုကို မျှဝေနေသည်}other{စာသားပါသောဗီဒီယို # ခုကို မျှဝေနေသည်}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{လင့်ခ်ပါသောဗီဒီယိုကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဗီဒီယို # ခုကို မျှဝေနေသည်}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{စာသားပါသောဖိုင်ကို မျှဝေနေသည်}other{စာသားပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{လင့်ခ်ပါသောဖိုင်ကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"အယ်လ်ဘမ် မျှဝေနေသည်"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ပုံသာလျှင်}other{ပုံများသာလျှင်}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ဗီဒီယိုသာလျှင်}other{ဗီဒီယိုများသာလျှင်}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ဖိုင်သာလျှင်}other{ဖိုင်များသာလျှင်}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ဤအက်ပ်ကို အသံဖမ်းခွင့် ပေးမထားသော်လည်း ၎င်းသည် ဤ USB စက်ပစ္စည်းမှတစ်ဆင့် အသံများကို ဖမ်းယူနိုင်ပါသည်။"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ကိုယ်ပိုင်"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"အလုပ်"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"သီးသန့်"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ပုဂ္ဂိုလ်ရေးဆိုင်ရာ မြင်ကွင်း"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"အလုပ် မြင်ကွင်း"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"သီးသန့်ပြသခြင်း"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"သင်၏ IT စီမံခန့်ခွဲသူက ပိတ်ထားသည်"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"အလုပ်သုံးအက်ပ်များကို ခေတ္တရပ်ထားသည်"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ပြန်ဖွင့်ရန်"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"အလုပ်သုံးအက်ပ်များ မရှိပါ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ကိုယ်ပိုင်အက်ပ်များ မရှိပါ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"သီးသန့်အက်ပ် မရှိပါ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ကို သင့်ကိုယ်ပိုင်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ကို သင့်အလုပ်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ကိုယ်ပိုင်ဘရောင်ဇာ သုံးရန်"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"စာသားထည့်သွင်းရန်"</string>
<string name="exclude_link" msgid="1332778255031992228">"လင့်ခ် ဖယ်ထုတ်ရန်"</string>
<string name="include_link" msgid="827855767220339802">"လင့်ခ်ထည့်သွင်းရန်"</string>
+ <string name="pinned" msgid="7623664001331394139">"ပင်ထိုးထားသည်"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ရွေးချယ်နိုင်သောပုံ"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ရွေးချယ်နိုင်သော ဗီဒီယို"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ရွေးချယ်နိုင်သောအရာ"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ခလုတ်"</string>
</resources>
diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml
index d2574230..bd31a926 100644
--- a/java/res/values-nb/strings.xml
+++ b/java/res/values-nb/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Endre"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # fil}other{+ # filer}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # fil til}other{+ # filer til}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Deler teksten"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Deler tekst"</string>
<string name="sharing_link" msgid="2307694372813942916">"Deler linken"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deler bildet}other{Deler # bilder}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deler videoen}other{Deler # videoer}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deler # fil}other{Deler # filer}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Velg elementene du vil dele"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deler bildet med tekst}other{Deler # bilder med tekst}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deler bildet med link}other{Deler # bilder med link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deler videoen med tekst}other{Deler # videoer med tekst}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler videoen med link}other{Deler # videoer med link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler filen med tekst}other{Deler # filer med tekst}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler filen med link}other{Deler # filer med link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deler album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Bare bildet}other{Bare bildene}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bare videoen}other{Bare videoene}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Bare filen}other{Bare filene}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne appen har ikke fått tillatelse til å spille inn, men kan ta opp lyd med denne USB-enheten."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig visning"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvisning"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat visning"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokkert av IT-administratoren din"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette innholdet kan ikke deles med jobbapper"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette innholdet kan ikke åpnes med jobbapper"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette innholdet kan ikke deles med personlige apper"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette innholdet kan ikke åpnes med personlige apper"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette innholdet kan ikke deles med private apper"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette innholdet kan ikke åpnes med private apper"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbapper er satt på pause"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Slå av pausen"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ingen jobbapper"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ingen personlige apper"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apper"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i den personlige profilen din?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i jobbprofilen din?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Bruk den personlige nettleseren"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inkluder teksten"</string>
<string name="exclude_link" msgid="1332778255031992228">"Ekskluder linken"</string>
<string name="include_link" msgid="827855767220339802">"Inkluder linken"</string>
+ <string name="pinned" msgid="7623664001331394139">"Festet"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Bilde som kan velges"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video som kan velges"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Element som kan velges"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Knapp"</string>
</resources>
diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml
index 40b3a6c7..620e402c 100644
--- a/java/res/values-ne/strings.xml
+++ b/java/res/values-ne/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"यस मार्फत छविलाई कैंद गर्नुहोस्"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> मार्फत फोटो खिच्नुहोस्"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"छविलाई कैंद गर्नुहोस्"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"फरक एप प्रयोग गर्नुहोस्"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"अर्को एप प्रयोग गर्नुहोस्"</string>
<string name="chooseActivity" msgid="6659724877523973446">"कारबाही चयन गर्नुहोस्"</string>
<string name="noApplications" msgid="1139487441772284671">"कुनै पनि एपहरूले यो कार्य गर्न सक्दैनन्।"</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"तपाईं तपाईंको कार्य प्रोफाइल बाहिर यो एप प्रयोग गरिरहनु भएको छ"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{फोटो सेयर गरिँदै छ}other{# वटा फोटो सेयर गरिँदै छ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{भिडियो सेयर गरिँदै छ}other{# वटा भिडियो सेयर गरिँदै छ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# वटा फाइल सेयर गरिँदै छ}other{# वटा फाइल सेयर गरिँदै छ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"आफूले सेयर गर्न चाहेका सामग्री चयन गर्नुहोस्"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{टेक्स्ट भएको फोटो सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा फोटो सेयर गरिँदै छन्}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंक भएको फोटो सेयर गरिँदै छ}other{लिंक भएका # वटा फोटो सेयर गरिँदै छन्}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{टेक्स्ट भएको भिडियो सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा भिडियो सेयर गरिँदै छन्}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक भएको भिडियो सेयर गरिँदै छ}other{लिंक भएका # वटा भिडियो सेयर गरिँदै छन्}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट भएको फाइल सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा फाइल सेयर गरिँदै छन्}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक भएको फाइल सेयर गरिँदै छ}other{लिंक भएका # वटा फाइल सेयर गरिँदै छन्}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"एल्बम सेयर गरिँदै छ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फोटो मात्र}other{फोटोहरू मात्र}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{भिडियो मात्र}other{भिडियोहरू मात्र}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फाइल मात्र}other{फाइलहरू मात्र}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"यो एपलाई रेकर्ड गर्ने अनुमति प्रदान गरिएको छैन तर यसले यो USB यन्त्रमार्फत अडियो क्याप्चर गर्न सक्छ।"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"व्यक्तिगत"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"काम"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"निजी स्पेस"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"व्यक्तिगत दृश्य"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी भ्यू"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तपाईंका IT एड्मिनले ब्लक गर्नुभएको छ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"यो सामग्री कामसम्बन्धी एपहरूमार्फत सेयर गर्न मिल्दैन"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"यो सामग्री कामसम्बन्धी एपहरूमार्फत खोल्न मिल्दैन"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"यो सामग्री व्यक्तिगत एपहरूमार्फत सेयर गर्न मिल्दैन"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"यो सामग्री व्यक्तिगत एपहरूमार्फत खोल्न मिल्दैन"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"यो सामग्री निजी एपहरूमार्फत सेयर गर्न मिल्दैन"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"यो सामग्री निजी एपहरूमार्फत खोल्न मिल्दैन"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामसम्बन्धी एपहरू पज गरिएका छन्"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"अनपज गर्नुहोस्"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यो सामग्री खोल्न मिल्ने कुनै पनि कामसम्बन्धी एप छैन"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यो सामग्री खोल्न मिल्ने कुनै पनि व्यक्तिगत एप छैन"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कुनै पनि निजी एप छैन"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> तपाईंको व्यक्तिगत प्रोफाइलमा खोल्ने हो?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> तपाईंको कार्य प्रोफाइलमा खोल्ने हो?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"व्यक्तिगत ब्राउजर प्रयोग गर्नुहोस्"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"टेक्स्ट समावेश गर्नुहोस्"</string>
<string name="exclude_link" msgid="1332778255031992228">"लिंक हटाउनुहोस्"</string>
<string name="include_link" msgid="827855767220339802">"लिंक समावेश गर्नुहोस्"</string>
+ <string name="pinned" msgid="7623664001331394139">"पिन गरिएको"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"चयन गर्न मिल्ने फोटो"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"चयन गर्न मिल्ने भिडियो"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"चयन गर्न मिल्ने वस्तु"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"बटन"</string>
</resources>
diff --git a/java/res/values-night/styles.xml b/java/res/values-night/styles.xml
new file mode 100644
index 00000000..95071bac
--- /dev/null
+++ b/java/res/values-night/styles.xml
@@ -0,0 +1,22 @@
+<!--
+ ~ Copyright (C) 2022 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 xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
+
+ <style name="Theme.DeviceDefault.Resolver" parent="Theme.DeviceDefault.ResolverCommon">
+ <item name="android:windowLightNavigationBar">false</item>
+ </style>
+</resources>
diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml
index 6e005494..54123bef 100644
--- a/java/res/values-nl/strings.xml
+++ b/java/res/values-nl/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Afbeelding delen}other{# afbeeldingen delen}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video delen}other{# video\'s delen}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# bestand delen}other{# bestanden delen}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Items selecteren om te delen"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Afbeelding met tekst wordt gedeeld}other{# afbeeldingen met tekst worden gedeeld}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Afbeelding delen via link}other{# afbeeldingen delen via link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Video delen via tekstbericht}other{# video\'s delen via tekstbericht}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video delen via link}other{# video\'s delen via link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bestand delen via tekstbericht}other{# bestanden delen via tekstbericht}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bestand delen via link}other{# bestanden delen via link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Album wordt gedeeld"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Alleen afbeelding}other{Alleen afbeeldingen}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Alleen video}other{Alleen video\'s}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Alleen bestand}other{Alleen bestanden}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Deze app heeft geen opnamerechten gekregen, maar zou audio kunnen vastleggen via dit USB-apparaat."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlijk"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlijke weergave"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkweergave"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privéweergave"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Geblokkeerd door je IT-beheerder"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Deze content kan niet worden gedeeld met werk-apps"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Deze content kan niet worden geopend met werk-apps"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Deze content kan niet worden gedeeld met persoonlijke apps"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Deze content kan niet worden geopend met persoonlijke apps"</string>
- <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werk-apps zijn onderbroken"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Deze content kan niet worden gedeeld met privé-apps"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Deze content kan niet worden geopend met privé-apps"</string>
+ <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werk-apps zijn gepauzeerd"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervatten"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werk-apps"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlijke apps"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen privé-apps"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> openen in je persoonlijke profiel?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> openen in je werkprofiel?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Persoonlijke browser gebruiken"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Tekst opnemen"</string>
<string name="exclude_link" msgid="1332778255031992228">"Link uitsluiten"</string>
<string name="include_link" msgid="827855767220339802">"Link opnemen"</string>
+ <string name="pinned" msgid="7623664001331394139">"Vastgezet"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Selecteerbare afbeelding"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Selecteerbare video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Selecteerbaar item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Knop"</string>
</resources>
diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml
index a4eb4de4..785acbe1 100644
--- a/java/res/values-or/strings.xml
+++ b/java/res/values-or/strings.xml
@@ -32,7 +32,7 @@
<string name="whichEditApplicationLabel" msgid="5992662938338600364">"ଏଡିଟ କରନ୍ତୁ"</string>
<string name="whichSendApplication" msgid="59510564281035884">"ସେୟାର କରନ୍ତୁ"</string>
<string name="whichSendApplicationNamed" msgid="495577664218765855">"<xliff:g id="APP">%1$s</xliff:g> ସହ ସେୟାର କରନ୍ତୁ"</string>
- <string name="whichSendApplicationLabel" msgid="2391198069286568035">"ସେୟାର୍‌ କରନ୍ତୁ"</string>
+ <string name="whichSendApplicationLabel" msgid="2391198069286568035">"ସେୟାର କରନ୍ତୁ"</string>
<string name="whichSendToApplication" msgid="2724450540348806267">"ଏହା ଜରିଆରେ ପଠାନ୍ତୁ"</string>
<string name="whichSendToApplicationNamed" msgid="1996548940365954543">"<xliff:g id="APP">%1$s</xliff:g> ବ୍ୟବହାର କରି ପଠାନ୍ତୁ"</string>
<string name="whichSendToApplicationLabel" msgid="6909037198280591110">"ପଠାନ୍ତୁ"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ଇମେଜ ସେୟାର କରାଯାଉଛି}other{#ଟିି ଇମେଜ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{#ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{#ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{#ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ସେୟାର କରିବା ପାଇଁ ଆଇଟମଗୁଡ଼ିକ ଚୟନ କରନ୍ତୁ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଇମେଜ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଇମେଜ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ଲିଙ୍କ ସହ ଇମେଜ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଇମେଜ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ଲିଙ୍କ ସହ ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ଲିଙ୍କ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ଆଲବମ ସେୟାର କରାଯାଉଛି"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{କେବଳ ଇମେଜ}other{କେବଳ ଇମେଜଗୁଡ଼ିକ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{କେବଳ ଭିଡିଓ}other{କେବଳ ଭିଡିଓଗୁଡ଼ିକ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{କେବଳ ଫାଇଲ}other{କେବଳ ଫାଇଲଗୁଡ଼ିକ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ଏହି ଆପ୍‌କୁ ରେକର୍ଡ କରିବାକୁ ଅନୁମତି ଦିଆଯାଇ ନାହିଁ କିନ୍ତୁ ଏହି USB ଡିଭାଇସ୍ ଜରିଆରେ ଅଡିଓ କ୍ୟାପ୍‍ଚର୍‍ କରିପାରିବ।"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ବ୍ୟକ୍ତିଗତ"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ୱାର୍କ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ପ୍ରାଇଭେଟ"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ବ୍ୟକ୍ତିଗତ ଭ୍ୟୁ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ୱାର୍କ ଭ୍ୟୁ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ପ୍ରାଇଭେଟ ଭ୍ୟୁ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ଆପଣଙ୍କ IT ଆଡମିନଙ୍କ ଦ୍ୱାରା ବ୍ଲକ୍ କରାଯାଇଛି"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ସେୟାର କରାଯାଇପାରିବ ନାହିଁ"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ୱାର୍କ ଆପ୍ସକୁ ବିରତ କରାଯାଇଛି"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ପୁଣି ଚାଲୁ କରନ୍ତୁ"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"କୌଣସି ୱାର୍କ ଆପ୍ ନାହିଁ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"କୌଣସି ବ୍ୟକ୍ତିଗତ ଆପ୍ ନାହିଁ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"କୌଣସି ପ୍ରାଇଭେଟ ଆପ୍ସ ନାହିଁ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ବ୍ୟକ୍ତିଗତ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ୱାର୍କ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ବ୍ୟକ୍ତିଗତ ବ୍ରାଉଜର୍ ବ୍ୟବହାର କରନ୍ତୁ"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ଟେକ୍ସଟକୁ ଅନ୍ତର୍ଭୁକ୍ତ କରନ୍ତୁ"</string>
<string name="exclude_link" msgid="1332778255031992228">"ଲିଙ୍କକୁ ବାଦ ଦିଅନ୍ତୁ"</string>
<string name="include_link" msgid="827855767220339802">"ଲିଙ୍କକୁ ଅନ୍ତର୍ଭୁକ୍ତ କରନ୍ତୁ"</string>
+ <string name="pinned" msgid="7623664001331394139">"ପିନ କରାଯାଇଛି"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ଚୟନ କରାଯାଇପାରୁଥିବା ଇମେଜ"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ଚୟନ କରାଯାଇପାରୁଥିବା ଭିଡିଓ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ଚୟନ କରାଯାଇପାରୁଥିବା ଆଇଟମ"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ବଟନ"</string>
</resources>
diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml
index 559832ce..8b9f528c 100644
--- a/java/res/values-pa/strings.xml
+++ b/java/res/values-pa/strings.xml
@@ -42,7 +42,7 @@
<string name="whichImageCaptureApplication" msgid="7830965894804399333">"ਇਸ ਨਾਲ ਚਿਤਰ ਕੈਪਚਰ ਕਰੋ"</string>
<string name="whichImageCaptureApplicationNamed" msgid="5927801386307049780">"<xliff:g id="APP">%1$s</xliff:g> ਨਾਲ ਚਿੱਤਰ ਨੂੰ ਕੈਪਚਰ ਕਰੋ"</string>
<string name="whichImageCaptureApplicationLabel" msgid="987153638235357094">"ਚਿਤਰ ਕੈਪਚਰ ਕਰੋ"</string>
- <string name="use_a_different_app" msgid="2062380818535918975">"ਇੱਕ ਵੱਖਰਾ ਖਾਤਾ ਵਰਤੋ"</string>
+ <string name="use_a_different_app" msgid="2062380818535918975">"ਕੋਈ ਵੱਖਰੀ ਐਪ ਵਰਤੋ"</string>
<string name="chooseActivity" msgid="6659724877523973446">"ਕਾਰਵਾਈ ਚੁਣੋ"</string>
<string name="noApplications" msgid="1139487441772284671">"ਕੋਈ ਐਪਾਂ ਇਸ ਕਾਰਵਾਈ ਨੂੰ ਨਹੀਂ ਕਰ ਸਕਦੀਆਂ।"</string>
<string name="forward_intent_to_owner" msgid="6454987608971162379">"ਤੁਸੀਂ ਇਹ ਐਪ ਆਪਣੀ ਕਾਰਜ ਪ੍ਰੋਫਾਈਲ ਦੇ ਬਾਹਰ ਵਰਤ ਰਹੇ ਹੋ"</string>
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ਚਿੱਤਰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{# ਚਿੱਤਰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{# ਚਿੱਤਰ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{# ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{# ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ਫ਼ਾਈਲ ਸਾਂਝੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ}one{# ਫ਼ਾਈਲ ਸਾਂਝੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ}other{# ਫ਼ਾਈਲਾਂ ਸਾਂਝੀਆਂ ਕੀਤੀਆਂ ਜਾ ਰਹੀਆਂ ਹਨ}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"ਸਾਂਝਾ ਕਰਨ ਲਈ ਆਈਟਮਾਂ ਚੁਣੋ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਚਿੱਤਰਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਚਿੱਤਰਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ਐਲਬਮ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ਸਿਰਫ਼ ਚਿੱਤਰ}one{ਸਿਰਫ਼ ਚਿੱਤਰ}other{ਸਿਰਫ਼ ਚਿੱਤਰ}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ਸਿਰਫ਼ ਵੀਡੀਓ}one{ਸਿਰਫ਼ ਵੀਡੀਓ}other{ਸਿਰਫ਼ ਵੀਡੀਓ}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ਸਿਰਫ਼ ਫ਼ਾਈਲ}one{ਸਿਰਫ਼ ਫ਼ਾਈਲ}other{ਸਿਰਫ਼ ਫ਼ਾਈਲਾਂ}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ਇਸ ਐਪ ਨੂੰ ਰਿਕਾਰਡ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਦਿੱਤੀ ਗਈ ਪਰ ਇਹ USB ਡੀਵਾਈਸ ਰਾਹੀਂ ਆਡੀਓ ਕੈਪਚਰ ਕਰ ਸਕਦੀ ਹੈ।"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ਨਿੱਜੀ"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"ਕੰਮ ਸੰਬੰਧੀ"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ਪ੍ਰਾਈਵੇਟ"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ਵਿਅਕਤੀਗਤ ਦ੍ਰਿਸ਼"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ਕਾਰਜ ਦ੍ਰਿਸ਼"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ਨਿੱਜੀ ਦ੍ਰਿਸ਼"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ਤੁਹਾਡੇ ਆਈ.ਟੀ. ਪ੍ਰਸ਼ਾਸਕ ਵੱਲੋਂ ਬਲਾਕ ਕੀਤਾ ਗਿਆ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨੂੰ ਰੋਕਿਆ ਗਿਆ ਹੈ"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ਰੋਕ ਹਟਾਓ"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ਕੋਈ ਕੰਮ ਸੰਬੰਧੀ ਐਪ ਨਹੀਂ"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ਕੀ ਆਪਣੇ ਨਿੱਜੀ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"ਕੀ ਆਪਣੇ ਕਾਰਜ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ਨਿੱਜੀ ਬ੍ਰਾਊਜ਼ਰ ਵਰਤੋ"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ਲਿਖਤ ਨੂੰ ਸ਼ਾਮਲ ਕਰੋ"</string>
<string name="exclude_link" msgid="1332778255031992228">"ਲਿੰਕ ਨੂੰ ਸ਼ਾਮਲ ਨਾ ਕਰੋ"</string>
<string name="include_link" msgid="827855767220339802">"ਲਿੰਕ ਸ਼ਾਮਲ ਕਰੋ"</string>
+ <string name="pinned" msgid="7623664001331394139">"ਪਿੰਨ ਕੀਤਾ ਗਿਆ"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ਚੁਣਨਯੋਗ ਚਿੱਤਰ"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ਚੁਣਨਯੋਗ ਵੀਡੀਓ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ਚੁਣਨਯੋਗ ਆਈਟਮ"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ਬਟਨ"</string>
</resources>
diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml
index 8a571173..3de2b1f4 100644
--- a/java/res/values-pl/strings.xml
+++ b/java/res/values-pl/strings.xml
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{I jeszcze # plik}few{I jeszcze # pliki}many{I jeszcze # plików}other{I jeszcze # pliku}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Udostępnianie tekstu"</string>
<string name="sharing_link" msgid="2307694372813942916">"Udostępnianie linku"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Udostępnianie obrazu}few{Udostępnianie # obrazów}many{Udostępnianie # obrazów}other{Udostępnianie # obrazu}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Udostępniam obraz}few{Udostępniam # obrazy}many{Udostępniam # obrazów}other{Udostępniam # obrazu}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Udostępnianie filmu}few{Udostępnianie # filmów}many{Udostępnianie # filmów}other{Udostępnianie # filmu}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Udostępnianie # pliku}few{Udostępnianie # plików}many{Udostępnianie # plików}other{Udostępnianie # pliku}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Wybierz elementy do udostępnienia"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Udostępnianie obrazu przez SMS}few{Udostępnianie # obrazów przez SMS}many{Udostępnianie # obrazów przez SMS}other{Udostępnianie # obrazu przez SMS}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Udostępnianie obrazu przez link}few{Udostępnianie # obrazów przez link}many{Udostępnianie # obrazów przez link}other{Udostępnianie # obrazu przez link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Udostępnianie filmu przez SMS}few{Udostępnianie # filmów przez SMS}many{Udostępnianie # filmów przez SMS}other{Udostępnianie # filmu przez SMS}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Udostępnianie filmu przez link}few{Udostępnianie # filmów przez link}many{Udostępnianie # filmów przez link}other{Udostępnianie # filmu przez link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Udostępnianie pliku przez SMS}few{Udostępnianie # plików przez SMS}many{Udostępnianie # plików przez SMS}other{Udostępnianie # pliku przez SMS}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Udostępnianie pliku przez link}few{Udostępnianie # plików przez link}many{Udostępnianie # plików przez link}other{Udostępnianie # pliku przez link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Udostępnij album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tylko obraz}few{Tylko obrazy}many{Tylko obrazy}other{Tylko obrazy}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tylko film}few{Tylko filmy}many{Tylko filmy}other{Tylko filmy}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tylko plik}few{Tylko pliki}many{Tylko pliki}other{Tylko pliki}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacja nie ma uprawnień do nagrywania, ale może rejestrować dźwięk za pomocą tego urządzenia USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Osobiste"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Służbowe"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Prywatne"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Widok osobisty"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Widok służbowy"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Widok prywatny"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Działanie zablokowane przez administratora IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tych treści nie można udostępniać w aplikacjach służbowych"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tych treści nie można otworzyć w aplikacjach służbowych"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tych treści nie można udostępniać w aplikacjach osobistych"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tych treści nie można otworzyć w aplikacjach osobistych"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tych treści nie można udostępniać w aplikacjach prywatnych"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tych treści nie można otworzyć w aplikacjach prywatnych"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacje służbowe są wstrzymane"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Cofnij wstrzymanie"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Brak aplikacji służbowych"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Brak aplikacji osobistych"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Brak prywatnych aplikacji"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu osobistym?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu służbowym?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Użyj przeglądarki osobistej"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Dołącz tekst"</string>
<string name="exclude_link" msgid="1332778255031992228">"Wyklucz link"</string>
<string name="include_link" msgid="827855767220339802">"Dołącz link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Przypięte"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Obraz do wyboru"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Film do wyboru"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Element do wyboru"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Przycisk"</string>
</resources>
diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml
index e53e1931..5ed57493 100644
--- a/java/res/values-pt-rBR/strings.xml
+++ b/java/res/values-pt-rBR/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Compartilhando texto"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Compartilhando link"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhando imagem}one{Compartilhando # imagem}many{Compartilhando # de imagens}other{Compartilhando # imagens}}"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Compartilhar link"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhar imagem}one{Compartilhar # imagem}many{Compartilhar # de imagens}other{Compartilhar # imagens}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartilhando vídeo}one{Compartilhando # vídeo}many{Compartilhando # de vídeos}other{Compartilhando # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartilhando # arquivo}one{Compartilhando # arquivo}many{Compartilhando # de arquivos}other{Compartilhando # arquivos}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecione os itens para compartilhar"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartilhando imagem com texto}one{Compartilhando # imagem com texto}many{Compartilhando # de imagens com texto}other{Compartilhando # imagens com texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartilhando imagem com link}one{Compartilhando # imagem com link}many{Compartilhando # de imagens com link}other{Compartilhando # imagens com link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartilhando vídeo com texto}one{Compartilhando # vídeo com texto}many{Compartilhando # de vídeos com texto}other{Compartilhando # vídeos com texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluir texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string>
<string name="include_link" msgid="827855767220339802">"Incluir link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fixada"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagem selecionável"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo selecionável"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Item selecionável"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botão"</string>
</resources>
diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml
index 0ed5cf88..73d12957 100644
--- a/java/res/values-pt-rPT/strings.xml
+++ b/java/res/values-pt-rPT/strings.xml
@@ -60,33 +60,40 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partilhar imagem}many{Partilhar # imagens}other{Partilhar # imagens}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{A partilhar vídeo}many{A partilhar # vídeos}other{A partilhar # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{A partilhar # ficheiro}many{A partilhar # ficheiros}other{A partilhar # ficheiros}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecione itens para partilhar"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{A partilhar imagem com texto}many{A partilhar # imagens com texto}other{A partilhar # imagens com texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{A partilhar imagem com link}many{A partilhar # imagens com link}other{A partilhar # imagens com link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{A partilhar vídeo com texto}many{A partilhar # vídeos com texto}other{A partilhar # vídeos com texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{A partilhar vídeo com link}many{A partilhar # vídeos com link}other{A partilhar # vídeos com link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{A partilhar ficheiro com texto}many{A partilhar # ficheiros com texto}other{A partilhar # ficheiros com texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{A partilhar ficheiro com link}many{A partilhar # ficheiros com link}other{A partilhar # ficheiros com link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Partilhar álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Apenas imagem}many{Apenas imagens}other{Apenas imagens}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Apenas vídeo}many{Apenas vídeos}other{Apenas vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Apenas ficheiro}many{Apenas ficheiros}other{Apenas ficheiros}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Miniatura de pré-visualização da imagem"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Miniatura de pré-visualização do vídeo"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniatura de pré-visualização do ficheiro"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Não existem pessoas recomendadas com quem partilhar"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Sem pessoas recomendadas com quem partilhar"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta app não recebeu autorização de gravação, mas pode capturar áudio através deste dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista pessoal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabalho"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado pelo administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível partilhar este conteúdo com apps de trabalho"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir este conteúdo com apps de trabalho"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível partilhar este conteúdo com apps pessoais"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir este conteúdo com apps pessoais"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível partilhar este conteúdo com apps privadas"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir este conteúdo com apps privadas"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As apps de trabalho estão pausadas"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Retomar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Sem apps de trabalho"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Sem apps pessoais"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nenhuma app privada"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador pessoal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluir texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string>
<string name="include_link" msgid="827855767220339802">"Incluir link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Afixada"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagem selecionável"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo selecionável"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Item selecionável"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botão"</string>
</resources>
diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml
index e53e1931..5ed57493 100644
--- a/java/res/values-pt/strings.xml
+++ b/java/res/values-pt/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Compartilhando texto"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Compartilhando link"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhando imagem}one{Compartilhando # imagem}many{Compartilhando # de imagens}other{Compartilhando # imagens}}"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Compartilhar link"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhar imagem}one{Compartilhar # imagem}many{Compartilhar # de imagens}other{Compartilhar # imagens}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartilhando vídeo}one{Compartilhando # vídeo}many{Compartilhando # de vídeos}other{Compartilhando # vídeos}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartilhando # arquivo}one{Compartilhando # arquivo}many{Compartilhando # de arquivos}other{Compartilhando # arquivos}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selecione os itens para compartilhar"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartilhando imagem com texto}one{Compartilhando # imagem com texto}many{Compartilhando # de imagens com texto}other{Compartilhando # imagens com texto}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartilhando imagem com link}one{Compartilhando # imagem com link}many{Compartilhando # de imagens com link}other{Compartilhando # imagens com link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartilhando vídeo com texto}one{Compartilhando # vídeo com texto}many{Compartilhando # de vídeos com texto}other{Compartilhando # vídeos com texto}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Incluir texto"</string>
<string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string>
<string name="include_link" msgid="827855767220339802">"Incluir link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fixada"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagem selecionável"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vídeo selecionável"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Item selecionável"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Botão"</string>
</resources>
diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml
index ed949a6a..7c8816b6 100644
--- a/java/res/values-ro/strings.xml
+++ b/java/res/values-ro/strings.xml
@@ -60,33 +60,40 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Se trimite imaginea}few{Se trimit # imagini}other{Se trimit # de imagini}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Se trimite videoclipul}few{Se trimit # videoclipuri}other{Se trimit # de videoclipuri}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Se trimite un fișier}few{Se trimit # fișiere}other{Se trimit # de fișiere}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Selectează articole de trimis"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Se trimite imaginea cu text}few{Se trimit # imagini cu text}other{Se trimit # de imagini cu text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Se trimite imaginea cu linkul}few{Se trimit # imagini cu linkul}other{Se trimit # de imagini cu linkul}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Se trimite videoclipul cu text}few{Se trimit # videoclipuri cu text}other{Se trimit # de videoclipuri cu text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Se trimite videoclipul cu linkul}few{Se trimit # videoclipuri cu linkul}other{Se trimit # de videoclipuri cu linkul}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Se trimite fișierul cu text}few{Se trimit # fișiere cu text}other{Se trimit # de fișiere cu text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Se trimite fișierul cu linkul}few{Se trimit # fișiere cu linkul}other{Se trimit # de fișiere cu linkul}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Se permite accesul la album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Numai imaginea}few{Numai imaginile}other{Numai imaginile}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Numai videoclipul}few{Numai videoclipurile}other{Numai videoclipurile}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Numai fișierul}few{Numai fișierele}other{Numai fișierele}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Miniatură pentru previzualizarea imaginii"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Miniatură pentru previzualizarea videoclipului"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniatură pentru previzualizarea fișierului"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nu există persoane recomandate pentru permiterea accesului"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nu există persoane recomandate pentru trimitere"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Permisiunea de înregistrare nu a fost acordată aplicației, dar aceasta poate să înregistreze conținut audio prin intermediul acestui dispozitiv USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Serviciu"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Afișarea conținutului personal"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Afișarea conținutului de lucru"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Secțiunea Privat"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocat de administratorul IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Acest conținut nu poate fi trimis cu aplicații pentru lucru"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Acest conținut nu poate fi deschis cu aplicații pentru lucru"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Acest conținut nu poate fi trimis către aplicații personale"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Acest conținut nu poate fi deschis cu aplicații personale"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Acest conținut nu poate fi trimis cu aplicații private"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Acest conținut nu poate fi deschis cu aplicații private"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplicațiile pentru lucru sunt întrerupte"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivează"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nicio aplicație pentru lucru"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nicio aplicație personală"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nu există aplicații private"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul personal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul de serviciu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Folosește browserul personal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Include textul"</string>
<string name="exclude_link" msgid="1332778255031992228">"Exclude linkul"</string>
<string name="include_link" msgid="827855767220339802">"Include linkul"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fixat"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imagine care poate fi selectată"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Videoclip care poate fi selectat"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Articol care poate fi selectat"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Buton"</string>
</resources>
diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml
index 86cd0136..7a05c9d0 100644
--- a/java/res/values-ru/strings.xml
+++ b/java/res/values-ru/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Изменить"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{и ещё # файл}one{и ещё # файл}few{и ещё # файла}many{и ещё # файлов}other{и ещё # файла}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ ещё # файл}one{+ ещё # файл}few{+ ещё # файла}many{+ ещё # файлов}other{+ ещё # файла}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Отправка сообщения"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Поделиться текстом"</string>
<string name="sharing_link" msgid="2307694372813942916">"Отправка ссылки"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Отправка изображения}one{Отправка # изображения}few{Отправка # изображений}many{Отправка # изображений}other{Отправка # изображения}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Отправка видео}one{Отправка # видео}few{Отправка # видео}many{Отправка # видео}other{Отправка # видео}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Предоставляется доступ к # файлу}one{Предоставляется доступ к # файлу}few{Предоставляется доступ к # файлам}many{Предоставляется доступ к # файлам}other{Предоставляется доступ к # файла}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Выберите объекты для отправки"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Отправка изображения с текстом}one{Отправка # изображения с текстом}few{Отправка # изображений с текстом}many{Отправка # изображений с текстом}other{Отправка # изображения с текстом}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Отправка изображения со ссылкой}one{Отправка # изображения со ссылкой}few{Отправка # изображений со ссылкой}many{Отправка # изображений со ссылкой}other{Отправка # изображения со ссылкой}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Отправка видео с текстом}one{Отправка # видео с текстом}few{Отправка # видео с текстом}many{Отправка # видео с текстом}other{Отправка # видео с текстом}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Отправка видео со ссылкой}one{Отправка # видео со ссылкой}few{Отправка # видео со ссылкой}many{Отправка # видео со ссылкой}other{Отправка # видео со ссылкой}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Отправка файла с текстом}one{Отправка # файла с текстом}few{Отправка # файлов с текстом}many{Отправка # файлов с текстом}other{Отправка # файла с текстом}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Отправка файла со ссылкой}one{Отправка # файла со ссылкой}few{Отправка # файлов со ссылкой}many{Отправка # файлов со ссылкой}other{Отправка # файла со ссылкой}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Вы делитесь альбомом"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Только изображение}one{Только изображения}few{Только изображения}many{Только изображения}other{Только изображения}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Только видео}one{Только видео}few{Только видео}many{Только видео}other{Только видео}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Только файл}one{Только файлы}few{Только файлы}many{Только файлы}other{Только файлы}}"</string>
@@ -74,19 +76,24 @@
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Значок предварительного просмотра файла"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Рекомендованных получателей нет."</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложению не разрешено записывать звук, однако оно может делать это с помощью этого USB-устройства."</string>
- <string name="resolver_personal_tab" msgid="1381052735324320565">"Личное"</string>
- <string name="resolver_work_tab" msgid="3588325717455216412">"Рабочее"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"Личный"</string>
+ <string name="resolver_work_tab" msgid="3588325717455216412">"Рабочий"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Частный"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Просмотр личных данных"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Просмотр рабочих данных"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Частное пространство"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблокировано вашим администратором"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Этим контентом нельзя делиться с рабочими приложениями."</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Этот контент нельзя открыть в рабочем приложении."</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Этим контентом нельзя делиться с личными приложениями."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Этот контент нельзя открыть в личном приложении."</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Этим контентом нельзя делиться через частные приложения."</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Этот контент нельзя открыть в частном приложении."</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Рабочие приложения приостановлены"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Включить"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Не поддерживается рабочими приложениями."</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Не поддерживается личными приложениями."</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Частные приложения не поддерживаются."</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в личном профиле?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в рабочем профиле?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Использовать личный браузер"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Вернуть текст"</string>
<string name="exclude_link" msgid="1332778255031992228">"Исключить ссылку"</string>
<string name="include_link" msgid="827855767220339802">"Вернуть ссылку"</string>
+ <string name="pinned" msgid="7623664001331394139">"Закреплено"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Изображение, которое можно выбрать"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Видео, которое можно выбрать"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Объект, который можно выбрать"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Кнопка"</string>
</resources>
diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml
index bb8a1911..19af7794 100644
--- a/java/res/values-si/strings.xml
+++ b/java/res/values-si/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{රූපය බෙදා ගැනීම}one{රූප #ක් බෙදා ගැනීම}other{රූප #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{වීඩියෝව බෙදා ගැනීම}one{වීඩියෝ #ක් බෙදා ගැනීම}other{වීඩියෝ #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ගොනුවක් බෙදා ගැනීම}one{ගොනු #ක් බෙදා ගැනීම}other{ගොනු #ක් බෙදා ගැනීම}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"බෙදා ගැනීමට අයිතම තෝරන්න"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{පෙළ සමග රූපය බෙදා ගැනීම}one{පෙළ සමග රූප #ක් බෙදා ගැනීම}other{පෙළ සමග රූප #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{සබැඳිය සමග රූපය බෙදා ගැනීම}one{සබැඳිය සමග රූප #ක් බෙදා ගැනීම}other{සබැඳිය සමග රූප #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{පෙළ සමග වීඩියෝව බෙදා ගැනීම}one{පෙළ සමග වීඩියෝ #ක් බෙදා ගැනීම}other{පෙළ සමග වීඩියෝ #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{සබැඳිය සමග වීඩියෝව බෙදා ගැනීම}one{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}other{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{පෙළ සමග ගොනුව බෙදා ගැනීම}one{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}other{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{සබැඳිය සමග ගොනුව බෙදා ගැනීම}one{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}other{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ඇල්බමය බෙදා ගැනීම"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{රූපය පමණි}one{රූප පමණි}other{රූප පමණි}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{වීඩියෝව පමණි}one{වීඩියෝ පමණි}other{වීඩියෝ පමණි}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ගොනුව පමණි}one{ගොනු පමණි}other{ගොනු පමණි}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"මෙම යෙදුමට පටිගත කිරීම් අවසරයක් ලබා දී නොමැති නමුත් මෙම USB උපාංගය හරහා ශ්‍රව්‍ය ග්‍රහණය කර ගත හැකිය."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"පුද්ගලික"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"කාර්යාල"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"පෞද්ගලික"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"පෞද්ගලික දසුන"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"කාර්යාල දසුන"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"පෞද්ගලික දසුන"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ඔබගේ IT පරිපාලක විසින් අවහිර කර ඇත"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් බෙදා ගත නොහැක"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් විවෘත කළ නොහැක"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"කාර්යාල යෙදුම් විරාම කර ඇත"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"විරාම නොකරන්න"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"කාර්යාල යෙදුම් නැත"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"පුද්ගලික යෙදුම් නැත"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"පුද්ගලික යෙදුම් නැත"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ඔබගේ පුද්ගලික පැතිකඩ තුළ විවෘත කරන්නද?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ඔබගේ කාර්යාල පැතිකඩ තුළ විවෘත කරන්නද?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"පුද්ගලික බ්‍රව්සරය භාවිත කරන්න"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"පාඨය ඇතළත් කරන්න"</string>
<string name="exclude_link" msgid="1332778255031992228">"සබැඳිය බැහැර කරන්න"</string>
<string name="include_link" msgid="827855767220339802">"සබැඳිය ඇතුළත් කරන්න"</string>
+ <string name="pinned" msgid="7623664001331394139">"අමුණා ඇත"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"තෝරා ගත හැකි රූපය"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"තෝරා ගත හැකි වීඩියෝව"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"තෝරා ගත හැකි අයිතමය"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"බොත්තම"</string>
</resources>
diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml
index f1d4c6b5..36898690 100644
--- a/java/res/values-sk/strings.xml
+++ b/java/res/values-sk/strings.xml
@@ -55,17 +55,19 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Upraviť"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # súbor}few{+ # súbory}many{+ # files}other{+ # súborov}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{a # ďalší súbor}few{a # ďalšie súbory}many{+ # more files}other{a # ďalších súborov}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Zdieľa sa textová správa"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Zdieľanie textu"</string>
<string name="sharing_link" msgid="2307694372813942916">"Zdieľa sa odkaz"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Zdieľa sa obrázok}few{Zdieľajú sa # obrázky}many{Sharing # images}other{Zdieľa sa # obrázkov}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Zdieľanie obrázku}few{Zdieľanie # obrázkov}many{Sharing # images}other{Zdieľanie # obrázkov}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Zdieľa sa video}few{Zdieľajú sa # videá}many{Sharing # videos}other{Zdieľa sa # videí}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Zdieľa sa # súbor}few{Zdieľajú sa # súbory}many{Sharing # files}other{Zdieľa sa # súborov}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Vyberte položky na zdieľanie"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Zdieľa sa obrázok s textom}few{Zdieľajú sa # obrázky s textom}many{Sharing # images with text}other{Zdieľa sa # obrázkov s textom}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Zdieľa sa obrázok s odkazom}few{Zdieľajú sa # obrázky s odkazom}many{Sharing # images with link}other{Zdieľa sa # obrázkov s odkazom}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Zdieľa sa video s textom}few{Zdieľajú sa # videá s textom}many{Sharing # videos with text}other{Zdieľa sa # videí s textom}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Zdieľa sa video s odkazom}few{Zdieľajú sa # videá s odkazom}many{Sharing # videos with link}other{Zdieľa sa # videí s odkazom}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Zdieľa sa súbor s textom}few{Zdieľajú sa # súbory s textom}many{Sharing # files with text}other{Zdieľa sa # súborov s textom}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Zdieľa sa súbor s odkazom}few{Zdieľajú sa # súbory s odkazom}many{Sharing # files with link}other{Zdieľa sa # súborov s odkazom}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Zdieľanie albumu"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Iba obrázok}few{Iba obrázky}many{Iba obrázky}other{Iba obrázky}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Iba video}few{Iba videá}many{Iba videá}other{Iba videá}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Iba súbor}few{Iba súbory}many{Iba súbory}other{Iba súbory}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tejto aplikácii nebolo udelené povolenie na nahrávanie, ale môže nasnímať zvuk cez toto zariadenie USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Osobné"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Pracovné"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Súkromné"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobné zobrazenie"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovné zobrazenie"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Súkromné zobrazenie"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokované vaším správcom IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah sa nedá zdieľať pomocou pracovných aplikácií"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah sa nedá otvoriť pomocou pracovných aplikácií"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah sa nedá zdieľať pomocou osobných aplikácií"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah sa nedá otvoriť pomocou osobných aplikácií"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah sa nedá zdieľať pomocou súkromných aplikácií"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah sa nedá otvoriť pomocou súkromných aplikácií"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovné aplikácie sú pozastavené"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušiť pozastavenie"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žiadne pracovné aplikácie"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žiadne osobné aplikácie"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žiadne súkromné aplikácie"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v osobnom profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v pracovnom profile?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použiť osobný prehliadač"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Zahrnúť text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Vylúčiť odkaz"</string>
<string name="include_link" msgid="827855767220339802">"Zahrnúť odkaz"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pripnuté"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Vybrateľný obrázok"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Vybrateľné video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Vybrateľná položka"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Tlačidlo"</string>
</resources>
diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml
index dc2442e7..714ba171 100644
--- a/java/res/values-sl/strings.xml
+++ b/java/res/values-sl/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deljenje slike}one{Deljenje # slike}two{Deljenje # slik}few{Deljenje # slik}other{Deljenje # slik}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deljenje videoposnetka}one{Deljenje # videoposnetka}two{Deljenje # videoposnetkov}few{Deljenje # videoposnetkov}other{Deljenje # videoposnetkov}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deljenje # datoteke}one{Deljenje # datoteke}two{Deljenje # datotek}few{Deljenje # datotek}other{Deljenje # datotek}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Izbira elementov za deljenje"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deljenje slike z besedilom}one{Deljenje # slike z besedilom}two{Deljenje # slik z besedilom}few{Deljenje # slik z besedilom}other{Deljenje # slik z besedilom}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deljenje slike s povezavo}one{Deljenje # slike s povezavo}two{Deljenje # slik s povezavo}few{Deljenje # slik s povezavo}other{Deljenje # slik s povezavo}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deljenje videoposnetka z besedilom}one{Deljenje # videoposnetka z besedilom}two{Deljenje # videoposnetkov z besedilom}few{Deljenje # videoposnetkov z besedilom}other{Deljenje # videoposnetkov z besedilom}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deljenje videoposnetka s povezavo}one{Deljenje # videoposnetka s povezavo}two{Deljenje # videoposnetkov s povezavo}few{Deljenje # videoposnetkov s povezavo}other{Deljenje # videoposnetkov s povezavo}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deljenje datoteke z besedilom}one{Deljenje # datoteke z besedilom}two{Deljenje # datotek z besedilom}few{Deljenje # datotek z besedilom}other{Deljenje # datotek z besedilom}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deljenje datoteke s povezavo}one{Deljenje # datoteke s povezavo}two{Deljenje # datotek s povezavo}few{Deljenje # datotek s povezavo}other{Deljenje # datotek s povezavo}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Deljenje albuma"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}two{Samo slike}few{Samo slike}other{Samo slike}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videoposnetek}one{Samo videoposnetki}two{Samo videoposnetki}few{Samo videoposnetki}other{Samo videoposnetki}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}two{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija sicer nima dovoljenja za snemanje, vendar bi lahko zajemala zvok prek te naprave USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Osebno"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Delo"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Zasebno"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pogled osebnega profila"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pogled delovnega profila"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Zasebni pogled"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokiral skrbnik za IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Te vsebine ni mogoče deliti z delovnimi aplikacijami."</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Te vsebine ni mogoče odpreti z delovnimi aplikacijami."</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Te vsebine ni mogoče deliti z osebnimi aplikacijami."</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Te vsebine ni mogoče odpreti z osebnimi aplikacijami."</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Te vsebine ni mogoče deliti z zasebnimi aplikacijami"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Te vsebine ni mogoče odpreti z zasebnimi aplikacijami"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Delovne aplikacije so začasno zaustavljene"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Znova aktiviraj"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nobena delovna aplikacija ni na voljo"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nobena osebna aplikacija"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ni zasebnih aplikacij"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v osebnem profilu?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v delovnem profilu?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Uporabi osebni brskalnik"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Vključi besedilo"</string>
<string name="exclude_link" msgid="1332778255031992228">"Izloči povezavo"</string>
<string name="include_link" msgid="827855767220339802">"Vključi povezavo"</string>
+ <string name="pinned" msgid="7623664001331394139">"Pripeto"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Slika, ki jo je mogoče izbrati."</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Videoposnetek, ki ga je mogoče izbrati."</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Element, ki ga je mogoče izbrati."</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Gumb"</string>
</resources>
diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml
index 596efea4..db24392a 100644
--- a/java/res/values-sq/strings.xml
+++ b/java/res/values-sq/strings.xml
@@ -60,33 +60,40 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Po ndahet imazh}other{Po ndahen # imazhe}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Po ndahet videoja}other{Po ndahen # video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Po ndahet # skedar}other{Po ndahen # skedarë}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Zgjidh artikujt për t\'i ndarë"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Po ndahet një imazh me tekst}other{Po ndahen # imazhe me tekst}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Po ndahet një imazh me lidhje}other{Po ndahen # imazhe me lidhje}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Po ndahet një video me tekst}other{Po ndahen # video me tekst}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Po ndahet një video me lidhje}other{Po ndahen # video me lidhje}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Po ndahet një skedar me tekst}other{Po ndahen # skedarë me tekst}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Po ndahet një skedar me lidhje}other{Po ndahen # skedarë me lidhje}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albumi po ndahet"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vetëm imazhi}other{Vetëm imazhet}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vetëm videoja}other{Vetëm videot}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vetëm skedari}other{Vetëm skedarët}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Miniatura e pamjes paraprake të imazhit"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Miniatura e pamjes paraprake të videos"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniatura e pamjes paraprake të skedarit"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nuk ka persona të rekomanduar për ta ndarë"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nuk ka persona të rekomanduar për të ndarë"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Këtij aplikacioni nuk i është dhënë leje për regjistrim, por mund të regjistrojë audio përmes kësaj pajisjeje USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Puna"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pamja personale"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pamja e punës"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pamja private"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bllokuar nga administratori yt i teknologjisë së informacionit"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kjo përmbajtje nuk mund të ndahet me aplikacione pune"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kjo përmbajtje nuk mund të hapet me aplikacione pune"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kjo përmbajtje nuk mund të ndahet me aplikacione personale"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kjo përmbajtje nuk mund të hapet me aplikacione personale"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kjo përmbajtje nuk mund të ndahet me aplikacione private"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kjo përmbajtje nuk mund të hapet me aplikacione private"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacionet e punës janë vendosur në pauzë"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Hiq nga pauza"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nuk ka aplikacione pune"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nuk ka aplikacione personale"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nuk ka aplikacione private"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd personal?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd të punës?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Përdor shfletuesin personal"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Përfshi tekstin"</string>
<string name="exclude_link" msgid="1332778255031992228">"Përjashto lidhjen"</string>
<string name="include_link" msgid="827855767220339802">"Përfshi lidhjen"</string>
+ <string name="pinned" msgid="7623664001331394139">"U gozhdua"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Imazh që mund të zgjidhet"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video që mund të zgjidhet"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Artikull që mund të zgjidhet"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Buton"</string>
</resources>
diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml
index 2ff9cbe9..8591ef7d 100644
--- a/java/res/values-sr/strings.xml
+++ b/java/res/values-sr/strings.xml
@@ -57,17 +57,19 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ још # фајл}one{+ још # фајл}few{+ још # фајла}other{+ још # фајлова}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Дели се текст"</string>
<string name="sharing_link" msgid="2307694372813942916">"Дели се линк"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Дели се слика}one{Дели се # слика}few{Деле се # слике}other{Дели се # слика}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Дељење слике}one{Дељење # слике}few{Дељење # слике}other{Дељење # слика}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Дели се видео}one{Дели се # видео}few{Деле се # видео снимка}other{Дели се # видеа}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Дели се # фајл}one{Дели се # фајл}few{Деле се # фајла}other{Дели се # фајлова}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Изаберите ставке за дељење"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Дели се слика са текстом}one{Дели се # слика са текстом}few{Деле се # слике са текстом}other{Дели се # слика са текстом}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Дели се слика са линком}one{Дели се # слика са линком}few{Деле се # слике са линком}other{Дели се # слика са линком}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Дели се видео са текстом}one{Дели се # видео са текстом}few{Деле се # видео снимка са текстом}other{Дели се # видеа са текстом}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Дели се видео са линком}one{Дели се # видео са линком}few{Деле се # видео снимка са линком}other{Дели се # видеа са линком}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Дели се фајл са текстом}one{Дели се # фајл са текстом}few{Деле се # фајла са текстом}other{Дели се # фајлова са текстом}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Дели се фајл са линком}one{Дели се # фајл са линком}few{Деле се # фајла са линком}other{Дели се # фајлова са линком}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Дељени албум"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слике}few{Само слике}other{Само слике}}"</string>
- <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видео снимци}few{Само видео снимци}other{Само видео снимци}}"</string>
+ <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видеи}few{Само видеи}other{Само видеи}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само фајл}one{Само фајлови}few{Само фајлови}other{Само фајлови}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Сличица за преглед слике"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Сличица за преглед видеа"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ова апликација нема дозволу за снимање, али би могла да снима звук помоћу овог USB уређаја."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Лично"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Пословно"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Лични приказ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Приказ за посао"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватни приказ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокира ИТ администратор"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овај садржај не може да се дели помоћу пословних апликација"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овај садржај не може да се отвара помоћу пословних апликација"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овај садржај не може да се дели помоћу личних апликација"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овај садржај не може да се отвара помоћу личних апликација"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овај садржај не може да се дели помоћу приватних апликација"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овај садржај не може да се отвори помоћу приватних апликација"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Пословне апликације су паузиране"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Поново активирај"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема пословних апликација"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема личних апликација"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Без приватних апликација"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Желите да на личном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Желите да на пословном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи лични прегледач"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Уврсти текст"</string>
<string name="exclude_link" msgid="1332778255031992228">"Изузми линк"</string>
<string name="include_link" msgid="827855767220339802">"Уврсти линк"</string>
+ <string name="pinned" msgid="7623664001331394139">"Закачено"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Слика која може да се изабере"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Видео који може да се изабере"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Ставка која може да се изабере"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Дугме"</string>
</resources>
diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml
index 21a286d4..6810faa7 100644
--- a/java/res/values-sv/strings.xml
+++ b/java/res/values-sv/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Delar bild}other{Delar # bilder}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Delar video}other{Delar # videor}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Delar # fil}other{Delar # filer}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Välj objekt att dela"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Delar bild med text}other{Delar # bilder med text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Delar bild med länk}other{Delar # bilder med länk}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Delar video med text}other{Delar # videor med text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Delar video med länk}other{Delar # videor med länk}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Delar fil med text}other{Delar # filer med text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Delar fil med länk}other{Delar # filer med länk}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Delar album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Endast bild}other{Endast bilder}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Endast video}other{Endast videor}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Endast fil}other{Endast filer}}"</string>
@@ -74,19 +76,24 @@
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniatyr av förhandsgranskning av fil"</string>
<string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Inga rekommenderade personer att dela med"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Appen har inte fått inspelningsbehörighet men kan spela in ljud via denna USB-enhet."</string>
- <string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string>
+ <string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig vy"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvy"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat vy"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blockeras av IT-administratören"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Det här innehållet kan inte delas med jobbappar"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Det här innehållet kan inte öppnas med jobbappar"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Det här innehållet kan inte delas med privata appar"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Det här innehållet kan inte öppnas med privata appar"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Det här innehållet kan inte delas med privata appar"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Det här innehållet kan inte öppnas med privata appar"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbappar har pausats"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Återuppta"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Inga jobbappar"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Inga privata appar"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Inga privata appar"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din privata profil?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din jobbprofil?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Använd privat webbläsare"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Inkludera text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Uteslut länk"</string>
<string name="include_link" msgid="827855767220339802">"Inkludera länk"</string>
+ <string name="pinned" msgid="7623664001331394139">"Fäst"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Bild som kan markeras"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video som kan markeras"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Objekt som kan markeras"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Knapp"</string>
</resources>
diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml
index 45c758e8..77b83e99 100644
--- a/java/res/values-sw/strings.xml
+++ b/java/res/values-sw/strings.xml
@@ -55,38 +55,45 @@
<string name="screenshot_edit" msgid="3857183660047569146">"Badilisha"</string>
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ faili #}other{+ faili #}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Faili nyingine #}other{Faili zingine #}}"</string>
- <string name="sharing_text" msgid="8137537443603304062">"Inashiriki maandishi"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Inashiriki kiungo"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Inashiriki picha}other{Inashiriki picha #}}"</string>
- <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Inashiriki video}other{Inashiriki video #}}"</string>
+ <string name="sharing_text" msgid="8137537443603304062">"Kutuma maandishi"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Inatuma kiungo"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Kutuma picha}other{Kutuma picha #}}"</string>
+ <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Inatuma video}other{Inatuma video #}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Inashiriki faili #}other{Inashiriki faili #}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Chagua vipengee vya kutuma"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Inashiriki picha na maandishi}other{Inashiriki picha # na maandishi}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Inashiriki picha na kiungo}other{Inashiriki picha # na kiungo}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Inashiriki video na maandishi}other{Inashiriki video # na maandishi}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Inashiriki video na kiungo}other{Inashiriki video # na kiungo}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Inashiriki faili na maandishi}other{Inashiriki faili # na maandishi}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Inashiriki faili na kiungo}other{Inashiriki faili # na kiungo}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Kutumia albamu pamoja"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Picha pekee}other{Picha pekee}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video pekee}other{Video pekee}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faili pekee}other{Faili pekee}}"</string>
<string name="image_preview_a11y_description" msgid="297102643932491797">"Kijipicha cha onyesho la kukagua picha"</string>
<string name="video_preview_a11y_description" msgid="683440858811095990">"Kijipicha cha onyesho la kukagua video"</string>
<string name="file_preview_a11y_description" msgid="7397224827802410602">"Kijipicha cha onyesho la kukagua faili"</string>
- <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Hujapendekezewa watu wa kushiriki nao"</string>
+ <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Hujapendekezewa watu wa kuwatumia"</string>
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Programu hii haijapewa ruhusa ya kurekodi lakini inaweza kurekodi sauti kupitia kifaa hiki cha USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Binafsi"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Kazini"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Faragha"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Mwonekano wa binafsi"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Mwonekano wa kazini"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Mwonekano wa faragha"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Imezuiwa na msimamizi wako wa Tehama"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Huwezi kushiriki maudhui haya na programu za kazini"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Huwezi kufungua maudhui haya ukitumia programu za kazini"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Huwezi kushiriki maudhui haya na programu za binafsi"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Huwezi kufungua maudhui haya ukitumia programu za binafsi"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Programu za faragha haziruhusiwi kufikia maudhui haya"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Huwezi kufungua maudhui haya ukitumia programu za faragha"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Programu za kazini zimesitishwa"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Acha kusitisha"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Hakuna programu za kazini"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Hakuna programu za binafsi"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Hakuna programu za faragha"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako binafsi?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako wa kazi?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Tumia kivinjari cha binafsi"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Jumuisha maandishi"</string>
<string name="exclude_link" msgid="1332778255031992228">"Usijumuishe kiungo"</string>
<string name="include_link" msgid="827855767220339802">"Jumuisha kiungo"</string>
+ <string name="pinned" msgid="7623664001331394139">"Imebandikwa"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Picha inayoweza kuchaguliwa"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video inayoweza kuchaguliwa"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Kipengee kinachoweza kuchaguliwa"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Kitufe"</string>
</resources>
diff --git a/java/res/values-sw600dp/dimens.xml b/java/res/values-sw600dp/dimens.xml
index 240ee067..e152ba06 100644
--- a/java/res/values-sw600dp/dimens.xml
+++ b/java/res/values-sw600dp/dimens.xml
@@ -20,4 +20,5 @@
<resources>
<dimen name="chooser_width">624dp</dimen>
<dimen name="modify_share_text_toggle_max_width">250dp</dimen>
+ <dimen name="chooser_item_focus_outline_corner_radius">16dp</dimen>
</resources>
diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml
index 43525f97..f53e5b29 100644
--- a/java/res/values-ta/strings.xml
+++ b/java/res/values-ta/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{படத்தைப் பகிர்கிறது}other{# படங்களைப் பகிர்கிறது}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{வீடியோவைப் பகிர்கிறது}other{# வீடியோக்களை பகிர்கிறது}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ஃபைலைப் பகிர்கிறது}other{# ஃபைல்களைப் பகிர்கிறது}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"பகிர விரும்புபவற்றைத் தேர்ந்தெடுத்தல்"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{வார்த்தைகளுடன் படத்தைப் பகிர்கிறது}other{வார்த்தைகளுடன் # படங்களைப் பகிர்கிறது}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{இணைப்பைக் கொண்ட படத்தைப் பகிர்கிறது}other{இணைப்பைக் கொண்ட # படங்களைப் பகிர்கிறது}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{வார்த்தைகளைக் கொண்ட வீடியோவைப் பகிர்கிறது}other{வார்த்தைகளைக் கொண்ட # வீடியோக்களைப் பகிர்கிறது}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{இணைப்புடன் வீடியோவைப் பகிர்கிறது}other{இணைப்புடன் # வீடியோக்களைப் பகிர்கிறது}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{வார்த்தைகளைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{வார்த்தைகளைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{இணைப்பைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{இணைப்பைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ஆல்பத்தைப் பகிர்தல்"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{படம் மட்டும்}other{படங்கள் மட்டும்}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{வீடியோ மட்டும்}other{வீடியோக்கள் மட்டும்}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ஃபைல் மட்டும்}other{ஃபைல்கள் மட்டும்}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"இந்த ஆப்ஸிற்கு ரெக்கார்டு செய்வதற்கான அனுமதி வழங்கப்படவில்லை, எனினும் இந்த USB சாதனம் மூலம் ஆடியோவைப் பதிவுசெய்ய முடியும்."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"தனிப்பட்ட சுயவிவரம்"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"பணிச் சுயவிவரம்"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"இரகசியமானவை"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"தனிப்பட்ட காட்சி"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"பணிக் காட்சி"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ரகசியக் காட்சி"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"இதை உங்கள் IT நிர்வாகி தடைசெய்துள்ளார்"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"பணி ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"பணி ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"பணி ஆப்ஸ் இடைநிறுத்தப்பட்டுள்ளன"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"மீண்டும் இயக்கு"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"பணி ஆப்ஸ் எதுவுமில்லை"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"உங்கள் தனிப்பட்ட கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"உங்கள் பணிக் கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"தனிப்பட்ட உலாவியைப் பயன்படுத்து"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"வார்த்தைகளைச் சேர்"</string>
<string name="exclude_link" msgid="1332778255031992228">"இணைப்பைத் தவிர்"</string>
<string name="include_link" msgid="827855767220339802">"இணைப்பைச் சேர்"</string>
+ <string name="pinned" msgid="7623664001331394139">"பின் செய்யப்பட்டுள்ளது"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"தேர்ந்தெடுக்கக்கூடிய படம்"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"தேர்ந்தெடுக்கக்கூடிய வீடியோ"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"தேர்ந்தெடுக்கக்கூடியது"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"பட்டன்"</string>
</resources>
diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml
index 589c5839..5003d8eb 100644
--- a/java/res/values-te/strings.xml
+++ b/java/res/values-te/strings.xml
@@ -32,7 +32,7 @@
<string name="whichEditApplicationLabel" msgid="5992662938338600364">"ఎడిట్"</string>
<string name="whichSendApplication" msgid="59510564281035884">"షేర్ చేయండి"</string>
<string name="whichSendApplicationNamed" msgid="495577664218765855">"<xliff:g id="APP">%1$s</xliff:g> యాప్‌తో షేర్ చేయండి"</string>
- <string name="whichSendApplicationLabel" msgid="2391198069286568035">"షేర్ చేయి"</string>
+ <string name="whichSendApplicationLabel" msgid="2391198069286568035">"షేర్ చేయండి"</string>
<string name="whichSendToApplication" msgid="2724450540348806267">"దీన్ని ఉపయోగించి పంపండి"</string>
<string name="whichSendToApplicationNamed" msgid="1996548940365954543">"<xliff:g id="APP">%1$s</xliff:g> యాప్‌ను ఉపయోగించి పంపండి"</string>
<string name="whichSendToApplicationLabel" msgid="6909037198280591110">"పంపండి"</string>
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ మరో # ఫైల్}other{+ మరో # ఫైల్స్}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"టెక్స్ట్‌ను షేర్ చేయడం"</string>
<string name="sharing_link" msgid="2307694372813942916">"లింక్‌ను షేర్ చేయడం"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ఇమేజ్‌ను షేర్ చేయడం}other{# ఇమేజ్‌లను షేర్ చేయడం}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ఈ ఇమేజ్‌ను షేర్ చేస్తున్నారు}other{ఈ # ఇమేజ్‌లను షేర్ చేస్తున్నారు}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{వీడియోను షేర్ చేయడం}other{# వీడియోలను షేర్ చేయడం}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ఫైల్‌ను షేర్ చేస్తోంది}other{# ఫైళ్లను షేర్ చేస్తోంది}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"షేర్ చేయడానికి ఐటెమ్‌లను ఎంచుకోండి"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా ఇమేజ్‌ను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # ఇమేజ్‌లను షేర్ చేయడం}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{లింక్ చేయడం ద్వారా ఇమేజ్‌ను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # ఇమేజ్‌లను షేర్ చేయడం}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా వీడియోను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # వీడియోలను షేర్ చేయడం}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{లింక్ చేయడం ద్వారా వీడియోను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # వీడియోలను షేర్ చేయడం}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా ఫైల్‌ను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # ఫైల్స్‌ను షేర్ చేయడం}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{లింక్ చేయడం ద్వారా ఫైల్‌ను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # ఫైల్స్‌ను షేర్ చేయడం}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"ఆల్బమ్ షేర్ చేయబడుతోంది"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ఇమేజ్ మాత్రమే}other{ఇమేజ్‌లు మాత్రమే}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{వీడియో మాత్రమే}other{వీడియోలు మాత్రమే}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ఫైల్ మాత్రమే}other{ఫైళ్లు మాత్రమే}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ఈ యాప్‌కు రికార్డ్ చేసే అనుమతి మంజూరు కాలేదు, అయినా ఈ USB పరికరం ద్వారా ఆడియోను క్యాప్చర్ చేయగలదు."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"వ్యక్తిగతం"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"వర్క్ ప్లేస్"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ప్రైవేట్"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"వ్యక్తిగత వీక్షణ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"పని వీక్షణ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ప్రైవేట్ వీక్షణ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"మీ IT అడ్మిన్ ద్వారా బ్లాక్ చేయబడింది"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ఈ కంటెంట్ వర్క్ యాప్‌తో షేర్ చేయడం సాధ్యం కాదు"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ఈ కంటెంట్ వర్క్ యాప్‌తో తెరవడం సాధ్యం కాదు"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ఈ కంటెంట్‌ను వ్యక్తిగత యాప్స్ లోకి షేర్ చేయడం సాధ్యం కాదు"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ఈ కంటెంట్ వ్యక్తిగత యాప్‌తో తెరవడం సాధ్యం కాదు"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ఈ కంటెంట్‌ను ప్రైవేట్ యాప్‌లతో షేర్ చేయడం సాధ్యం కాదు"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ఈ కంటెంట్‌ను ప్రైవేట్ యాప్‌లతో తెరవడం సాధ్యం కాదు"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"వర్క్ యాప్‌లు పాజ్ చేయబడ్డాయి"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"అన్‌పాజ్ చేయండి"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"వర్క్ యాప్‌లు లేవు"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"వ్యక్తిగత యాప్‌లు లేవు"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ప్రైవేట్ యాప్‌లు ఏవీ లేవు"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>ను మీ వ్యక్తిగత ప్రొఫైల్‌లో తెరవాలా?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>ను మీ వర్క్ ప్రొఫైల్‌లో తెరవాలా?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"వ్యక్తిగత బ్రౌజర్‌ను ఉపయోగించండి"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"టెక్స్ట్‌ను చేర్చండి"</string>
<string name="exclude_link" msgid="1332778255031992228">"లింక్‌ను మినహాయించండి"</string>
<string name="include_link" msgid="827855767220339802">"లింక్‌ను చేర్చండి"</string>
+ <string name="pinned" msgid="7623664001331394139">"పిన్ చేయబడింది"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"ఎంచుకోదగిన ఇమేజ్"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"ఎంచుకోదగిన వీడియో"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"ఎంచుకోదగిన ఐటెమ్"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"బటన్"</string>
</resources>
diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml
index 70b7c8f5..8bb9408f 100644
--- a/java/res/values-th/strings.xml
+++ b/java/res/values-th/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{กำลังแชร์รูปภาพ}other{กำลังแชร์รูปภาพ # รายการ}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{กำลังแชร์วิดีโอ}other{กำลังแชร์วิดีโอ # รายการ}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{กำลังจะแชร์ # ไฟล์}other{กำลังจะแชร์ # ไฟล์}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"เลือกรายการที่จะแชร์"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{กำลังแชร์รูปภาพพร้อมข้อความ}other{กำลังแชร์รูปภาพ # รายการพร้อมข้อความ}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{กำลังแชร์รูปภาพพร้อมลิงก์}other{กำลังแชร์รูปภาพ # รายการพร้อมลิงก์}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{กำลังแชร์วิดีโอพร้อมข้อความ}other{กำลังแชร์วิดีโอ # รายการพร้อมข้อความ}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{กำลังแชร์วิดีโอพร้อมลิงก์}other{กำลังแชร์วิดีโอ # รายการพร้อมลิงก์}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมข้อความ}other{กำลังแชร์ไฟล์ # รายการพร้อมข้อความ}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมลิงก์}other{กำลังแชร์ไฟล์ # รายการพร้อมลิงก์}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"กำลังแชร์อัลบั้ม"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{รูปภาพเท่านั้น}other{รูปภาพเท่านั้น}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{วิดีโอเท่านั้น}other{วิดีโอเท่านั้น}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ไฟล์เท่านั้น}other{ไฟล์เท่านั้น}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"แอปนี้ไม่ได้รับอนุญาตให้บันทึกเสียงแต่อาจเก็บเสียงผ่านอุปกรณ์ USB นี้ได้"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ส่วนตัว"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"งาน"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"ส่วนตัว"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"มุมมองส่วนตัว"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ดูงาน"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"มุมมองส่วนตัว"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ผู้ดูแลระบบไอทีบล็อกไว้"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"แชร์เนื้อหานี้โดยใช้แอปงานไม่ได้"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"เปิดเนื้อหานี้โดยใช้แอปงานไม่ได้"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"แอปงานหยุดชั่วคราว"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"ยกเลิกการหยุดชั่วคราว"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ไม่มีแอปงาน"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ไม่มีแอปส่วนตัว"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ไม่มีแอปส่วนตัว"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์ส่วนตัวไหม"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์งานไหม"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ใช้เบราว์เซอร์ส่วนตัว"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"รวมข้อความ"</string>
<string name="exclude_link" msgid="1332778255031992228">"ไม่รวมลิงก์"</string>
<string name="include_link" msgid="827855767220339802">"รวมลิงก์"</string>
+ <string name="pinned" msgid="7623664001331394139">"ปักหมุดไว้"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"รูปภาพที่เลือกได้"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"วิดีโอที่เลือกได้"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"รายการที่เลือกได้"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"ปุ่ม"</string>
</resources>
diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml
index e0c1c168..e98c06bf 100644
--- a/java/res/values-tl/strings.xml
+++ b/java/res/values-tl/strings.xml
@@ -32,7 +32,7 @@
<string name="whichEditApplicationLabel" msgid="5992662938338600364">"I-edit"</string>
<string name="whichSendApplication" msgid="59510564281035884">"Ibahagi"</string>
<string name="whichSendApplicationNamed" msgid="495577664218765855">"Ibahagi gamit ang <xliff:g id="APP">%1$s</xliff:g>"</string>
- <string name="whichSendApplicationLabel" msgid="2391198069286568035">"Ibahagi"</string>
+ <string name="whichSendApplicationLabel" msgid="2391198069286568035">"I-share"</string>
<string name="whichSendToApplication" msgid="2724450540348806267">"Ipadala gamit ang"</string>
<string name="whichSendToApplicationNamed" msgid="1996548940365954543">"Ipadala gamit ang <xliff:g id="APP">%1$s</xliff:g>"</string>
<string name="whichSendToApplicationLabel" msgid="6909037198280591110">"Ipadala"</string>
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # pang file}one{+ # pang file}other{+ # pang file}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Ibinabahagi ang text"</string>
<string name="sharing_link" msgid="2307694372813942916">"Ibinabahagi ang link"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Ibinabahagi ang larawan}one{Ibinabahagi ang # larawan}other{Ibinabahagi ang # na larawan}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Shine-share ang larawan}one{Shine-share ang # larawan}other{Shine-share ang # na larawan}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Ibinabahagi ang video}one{Ibinabahagi ang # video}other{Ibinabahagi ang # na video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Nagshe-share ng # file}one{Nagshe-share ng # file}other{Nagshe-share ng # na file}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Pumili ng mga item na ibabahagi"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Nagbabahagi ng larawang may text}one{Nagbabahagi ng # larawang may text}other{Nagbabahagi ng # na larawang may text}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Nagbabahagi ng larawang may link}one{Nagbabahagi ng # larawang may link}other{Nagbabahagi ng # na larawang may link}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Nagbabahagi ng video na may text}one{Nagbabahagi ng # video na may text}other{Nagbabahagi ng # na video na may text}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Nagbabahagi ng video na may link}one{Nagbabahagi ng # video na may link}other{Nagbabahagi ng # na video na may link}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Nagbabahagi ng file na may text}one{Nagbabahagi ng # file na may text}other{Nagbabahagi ng # na file na may text}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Nagbabahagi ng file na may link}one{Nagbabahagi ng # file na may link}other{Nagbabahagi ng # na file na may link}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Ibinabahagi ang album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Larawan lang}one{Mga larawan lang}other{Mga larawan lang}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video lang}one{Mga video lang}other{Mga video lang}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File lang}one{Mga file lang}other{Mga file lang}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Hindi nabigyan ng pahintulot ang app na ito para mag-record pero nakakapag-capture ito ng audio sa pamamagitan ng USB device na ito."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Trabaho"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Pribado"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal na view"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"View ng trabaho"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pribadong view"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Na-block ng iyong IT admin"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hindi puwedeng ibahagi sa mga app para sa trabaho ang content na ito"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hindi puwedeng buksan sa mga app para sa trabaho ang content na ito"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hindi puwedeng ibahagi sa mga personal na app ang content na ito"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hindi puwedeng buksan sa mga personal na app ang content na ito"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hindi maibabahagi ang content na ito sa mga pribadong app"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hindi mabubuksan ang content na ito gamit ang mga pribadong app"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Naka-pause ang mga app para sa trabaho"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"I-unpause"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Walang app para sa trabaho"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Walang personal na app"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Walang pribadong app"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong personal na profile?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong profile sa trabaho?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gamitin ang personal na browser"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Isama ang text"</string>
<string name="exclude_link" msgid="1332778255031992228">"Huwag isama ang link"</string>
<string name="include_link" msgid="827855767220339802">"Isama ang link"</string>
+ <string name="pinned" msgid="7623664001331394139">"Naka-pin"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Napipiling larawan"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Napipiling video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Napipiling item"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Button"</string>
</resources>
diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml
index 83c51cb3..25b7e860 100644
--- a/java/res/values-tr/strings.xml
+++ b/java/res/values-tr/strings.xml
@@ -56,16 +56,18 @@
<string name="other_files" msgid="4501185823517473875">"{count,plural, =1{+ # dosya}other{+ # dosya}}"</string>
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # dosya daha}other{+ # dosya daha}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Metin paylaşılıyor"</string>
- <string name="sharing_link" msgid="2307694372813942916">"Bağlantı paylaşılıyor"</string>
+ <string name="sharing_link" msgid="2307694372813942916">"Paylaşım bağlantısı"</string>
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Resim paylaşılıyor}other{# resim paylaşılıyor}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video paylaşılıyor}other{# video paylaşılıyor}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# dosya paylaşılıyor}other{# dosya paylaşılıyor}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Paylaşılacak öğeleri seçin"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Metin ekli resim paylaşılıyor}other{Metin ekli # resim paylaşılıyor}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bağlantı ekli resim paylaşılıyor}other{Bağlantı ekli # resim paylaşılıyor}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Metin ekli video paylaşılıyor}other{Metin ekli # video paylaşılıyor}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bağlantı ekli video paylaşılıyor}other{Bağlantı ekli # video paylaşılıyor}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Metin ekli dosya paylaşılıyor}other{Metin ekli # dosya paylaşılıyor}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bağlantı ekli dosya paylaşılıyor}other{Bağlantı ekli # dosya paylaşılıyor}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albüm paylaşılıyor"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnızca resim}other{Yalnızca resimler}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnızca video}other{Yalnızca videolar}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnızca dosya}other{Yalnızca dosyalar}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu uygulamaya ses kaydetme izni verilmedi ancak bu USB cihazı üzerinden sesleri yakalayabilir."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Kişisel"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Gizli"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Kişisel görünüm"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş görünümü"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Gizli görünüm"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"BT yöneticiniz tarafından engellendi"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu içerik, iş uygulamalarıyla paylaşılamaz"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu içerik, iş uygulamalarıyla açılamaz"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu içerik, kişisel uygulamalarla paylaşılamaz"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu içerik, kişisel uygulamalarla açılamaz"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu içerik, özel uygulamalarla paylaşılamaz"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu içerik, özel uygulamalarla açılamaz"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş uygulamaları duraklatıldı"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Devam ettir"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş uygulaması yok"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Kişisel uygulama yok"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Özel uygulama yok"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> uygulaması kişisel profilinizde açılsın mı?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> uygulaması iş profilinizde açılsın mı?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kişisel tarayıcıyı kullan"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Metni dahil et"</string>
<string name="exclude_link" msgid="1332778255031992228">"Bağlantıyı hariç tut"</string>
<string name="include_link" msgid="827855767220339802">"Bağlantıyı dahil et"</string>
+ <string name="pinned" msgid="7623664001331394139">"Sabitlendi"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Seçilebilir resim"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Seçilebilir video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Seçilebilir öğe"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Düğme"</string>
</resources>
diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml
index 46573598..33f9e350 100644
--- a/java/res/values-uk/strings.xml
+++ b/java/res/values-uk/strings.xml
@@ -57,15 +57,17 @@
<string name="more_files" msgid="1043875756612339842">"{count,plural, =1{і ще # файл}one{і ще # файл}few{і ще # файли}many{і ще # файлів}other{і ще # файлу}}"</string>
<string name="sharing_text" msgid="8137537443603304062">"Надсилається текст"</string>
<string name="sharing_link" msgid="2307694372813942916">"Надсилається посилання"</string>
- <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Надсилається зображення}one{Надсилається # зображення}few{Надсилаються # зображення}many{Надсилаються # зображень}other{Надсилається # зображення}}"</string>
+ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Надсилання зображення}one{Надсилання # зображення}few{Надсилання # зображень}many{Надсилання # зображень}other{Надсилання # зображення}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Надсилається відео}one{Надсилається # відео}few{Надсилаються # відео}many{Надсилаються # відео}other{Надсилається # відео}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Надсилається # файл}one{Надсилається # файл}few{Надсилаються # файли}many{Надсилаються # файлів}other{Надсилається # файлу}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Виберіть об’єкти, якими хочете поділитися"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Надсилання зображення з текстом}one{Надсилання # зображення з текстом}few{Надсилання # зображень із текстом}many{Надсилання # зображень із текстом}other{Надсилання # зображення з текстом}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Надсилання зображення з посиланням}one{Надсилання # зображення з посиланням}few{Надсилання # зображень із посиланням}many{Надсилання # зображень із посиланням}other{Надсилання # зображення з посиланням}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Надсилання відео з текстом}one{Надсилання # відео з текстом}few{Надсилання # відео з текстом}many{Надсилання # відео з текстом}other{Надсилання # відео з текстом}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Надсилання відео з посиланням}one{Надсилання # відео з посиланням}few{Надсилання # відео з посиланням}many{Надсилання # відео з посиланням}other{Надсилання # відео з посиланням}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Надсилання файлу з текстом}one{Надсилання # файлу з текстом}few{Надсилання # файлів із текстом}many{Надсилання # файлів із текстом}other{Надсилання # файлу з текстом}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Надсилання файлу з посиланням}one{Надсилання # файлу з посиланням}few{Надсилання # файлів із посиланням}many{Надсилання # файлів із посиланням}other{Надсилання # файлу з посиланням}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Надання спільного доступу до альбома"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Лише зображення}one{Лише зображення}few{Лише зображення}many{Лише зображення}other{Лише зображення}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Лише відео}one{Лише відео}few{Лише відео}many{Лише відео}other{Лише відео}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Лише файл}one{Лише файли}few{Лише файли}many{Лише файли}other{Лише файли}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Цей додаток не має дозволу на запис, але він може фіксувати звук через цей USB-пристрій."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Особисте"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Робоче"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Приватний простір"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Особистий перегляд"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Робочий перегляд"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватний перегляд"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблоковано адміністратором"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Цим контентом не можна ділитися в робочих додатках"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Цей контент не можна відкривати в робочих додатках"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Цим контентом не можна ділитися в особистих додатках"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Цей контент не можна відкривати в особистих додатках"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Цим контентом не можна ділитися в приватних додатках"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Цей контент не можна відкривати в приватних додатках"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Робочі додатки призупинено"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Увімкнути знову"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Немає робочих додатків"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Немає особистих додатків"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Немає приватних додатків"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> в особистому профілі?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> у робочому профілі?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Використати особистий веб-переглядач"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Додати текст"</string>
<string name="exclude_link" msgid="1332778255031992228">"Вилучити посилання"</string>
<string name="include_link" msgid="827855767220339802">"Додати посилання"</string>
+ <string name="pinned" msgid="7623664001331394139">"Закріплено"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Зображення, яке можна вибрати"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Відео, яке можна вибрати"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Об’єкт, який можна вибрати"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Кнопка"</string>
</resources>
diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml
index daec2511..950041e7 100644
--- a/java/res/values-ur/strings.xml
+++ b/java/res/values-ur/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{تصویر کا اشتراک کیا جا رہا ہے}other{# تصاویر کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ویڈیو کا اشتراک کیا جا رہا ہے}other{# ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# فائل کا اشتراک کیا جا رہا ہے}other{# فائلز کا اشتراک کیا جا رہا ہے}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"اشتراک کرنے کے لیے آئٹمز منتخب کریں"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ٹیکسٹ کے ساتھ تصویر کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # تصاویر کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{لنک کے ساتھ تصویر کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # تصاویر کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ٹیکسٹ کے ساتھ ویڈیو کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{لنک کے ساتھ ویڈیو کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ٹیکسٹ کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{لنک کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"البم کا اشتراک کیا جا رہا ہے"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{صرف تصویر}other{صرف تصاویر}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{صرف ویڈیو}other{صرف ویڈیوز}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{صرف فائل}other{صرف فائلز}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"‏اس ایپ کو ریکارڈ کرنے کی اجازت عطا نہیں کی گئی ہے مگر اس USB آلہ کے ذریعے آڈیو کیپچر کر سکتی ہے۔"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"ذاتی"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"دفتر"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"نجی"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ذاتی ملاحظہ"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"دفتری ملاحظہ"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نجی ملاحظہ"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"‏آپ کے IT منتظم کے ذریعے مسدود کردہ"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"اس مواد کا اشتراک ورک ایپس کے ساتھ نہیں کیا جا سکتا"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"اس مواد کو ورک ایپس کے ساتھ نہیں کھولا جا سکتا"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"اس مواد کا اشتراک ذاتی ایپس کے ساتھ نہیں کیا جا سکتا"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"اس مواد کا اشتراک نجی ایپس کے ساتھ نہیں کیا جا سکتا"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ورک ایپس موقوف ہیں"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"غیر موقوف کریں"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"کوئی ورک ایپ نہیں"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"کوئی ذاتی ایپ نہیں"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"کوئی نجی ایپ نہیں ہے"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"اپنی ذاتی پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"اپنی دفتری پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ذاتی براؤزر استعمال کریں"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"ٹیکسٹ شامل کریں"</string>
<string name="exclude_link" msgid="1332778255031992228">"لنک خارج کریں"</string>
<string name="include_link" msgid="827855767220339802">"لنک شامل کریں"</string>
+ <string name="pinned" msgid="7623664001331394139">"پن کردہ"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"قابل انتخاب تصویر"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"قابل انتخاب ویڈیو"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"قابل انتخاب آئٹم"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"بٹن"</string>
</resources>
diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml
index 83fd9333..1792e0d2 100644
--- a/java/res/values-uz/strings.xml
+++ b/java/res/values-uz/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Rasm ulashilmoqda}other{# ta rasm ulashilmoqda}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video ulashilmoqda}other{# ta video ulashilmoqda}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ta fayl ulashilmoqda}other{# ta fayl ulashilmoqda}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Ulashish uchun elementlarni tanlang"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Matnli havolani yuborish}other{# ta matnli havolani yuborish}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Havolali rasmni yuborish}other{# ta havolali rasmni yuborish}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Matnli videoni yuborish}other{# ta matnli videoni yuborish}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Havolali videoni yuborish}other{# ta havolali videoni yuborish}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Matnli faylni yuborish}other{# ta matnli faylni yuborish}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Havolali faylni yuborish}other{# ta havolali faylni yuborish}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Albom ulashilmoqda"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Faqat rasm}other{Faqat rasmlar}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Faqat video}other{Faqat videolar}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faqat fayl}other{Faqat fayllar}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu ilovaga yozib olish ruxsati berilmagan, lekin shu USB orqali ovozlarni yozib olishi mumkin."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Shaxsiy"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Ish"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Maxfiy"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Shaxsiy rejim"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ishchi rejim"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Maxfiy rejim"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Administratoringiz tomonidan bloklangan"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontent ishga oid ilovalar bilan ulashilmaydi"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontent ishga oid ilovalar bilan ochilmaydi"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontent shaxsiy ilovalar bilan ulashilmaydi"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontent shaxsiy ilovalar bilan ochilmaydi"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontent shaxsiy ilovalar orqali ulashilmaydi"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontent shaxsiy ilovalar orqali ochilmaydi"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ishga oid ilovalar pauza qilingan"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Davom ettirish"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ishga oid ilovalar topilmadi"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Shaxsiy ilovalar topilmadi"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Shaxsiy ilovalar ishlamaydi"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Shaxsiy brauzerdan foydalanish"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Matnni kiritish"</string>
<string name="exclude_link" msgid="1332778255031992228">"Havolani chiqarib tashlash"</string>
<string name="include_link" msgid="827855767220339802">"Havolani kiritish"</string>
+ <string name="pinned" msgid="7623664001331394139">"Mahkamlangan"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Tanlanadigan rasm"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Tanlanadigan video"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Tanlanadigan fayl"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Tugma"</string>
</resources>
diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml
index 9eb8af81..a32bacc1 100644
--- a/java/res/values-vi/strings.xml
+++ b/java/res/values-vi/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Chia sẻ hình ảnh}other{Chia sẻ # hình ảnh}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Đang chia sẻ video}other{Đang chia sẻ # video}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Đang chia sẻ # tệp}other{Đang chia sẻ # tệp}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Chọn mục muốn chia sẻ"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Đang chia sẻ hình ảnh có văn bản}other{Đang chia sẻ # hình ảnh có văn bản}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Đang chia sẻ hình ảnh có đường liên kết}other{Đang chia sẻ # hình ảnh có đường liên kết}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Đang chia sẻ video có văn bản}other{Đang chia sẻ # video có văn bản}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Đang chia sẻ video có đường liên kết}other{Đang chia sẻ # video có đường liên kết}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Đang chia sẻ tệp có văn bản}other{Đang chia sẻ # tệp có văn bản}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Đang chia sẻ tệp có đường liên kết}other{Đang chia sẻ # tệp có đường liên kết}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"Chia sẻ album"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Chỉ chia sẻ hình ảnh}other{Chỉ chia sẻ các hình ảnh}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Chỉ chia sẻ video}other{Chỉ chia sẻ các video}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Chỉ chia sẻ tệp}other{Chỉ chia sẻ các tệp}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ứng dụng này chưa được cấp quyền ghi âm nhưng vẫn có thể ghi âm thông qua thiết bị USB này."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Cá nhân"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Công việc"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Riêng tư"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Chế độ xem cá nhân"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Chế độ xem công việc"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Chế độ xem riêng tư"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bị quản trị viên CNTT chặn"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bạn không thể chia sẻ nội dung này bằng ứng dụng công việc"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bạn không thể mở nội dung này bằng ứng dụng công việc"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bạn không thể chia sẻ nội dung này bằng ứng dụng cá nhân"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bạn không thể mở nội dung này bằng ứng dụng cá nhân"</string>
- <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng công việc đã bị tạm dừng"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Không chia sẻ được nội dung này bằng ứng dụng riêng tư"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Không mở được nội dung này bằng ứng dụng riêng tư"</string>
+ <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng trong hồ sơ Công việc đã bị tạm dừng"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Tiếp tục"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Không có ứng dụng công việc"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Không có ứng dụng cá nhân"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Không có ứng dụng riêng tư nào"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ cá nhân của bạn?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ công việc của bạn?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Dùng trình duyệt cá nhân"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Thêm văn bản"</string>
<string name="exclude_link" msgid="1332778255031992228">"Không kèm đường liên kết"</string>
<string name="include_link" msgid="827855767220339802">"Thêm đường liên kết"</string>
+ <string name="pinned" msgid="7623664001331394139">"Đã ghim"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Hình ảnh có thể chọn"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Video có thể chọn"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Mục có thể chọn"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Nút"</string>
</resources>
diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml
index f008a9a3..603a4e5e 100644
--- a/java/res/values-zh-rCN/strings.xml
+++ b/java/res/values-zh-rCN/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{分享图片}other{分享 # 张图片}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{正在分享视频}other{正在分享 # 个视频}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{正在分享 # 个文件}other{正在分享 # 个文件}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"选择要分享的内容"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{正在分享带有文本的图片}other{正在分享带有文本的 # 个图片}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{正在分享带有链接的图片}other{正在分享带有链接的 # 个图片}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{正在分享带有文本的视频}other{正在分享带有文本的 # 个视频}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享带有链接的视频}other{正在分享带有链接的 # 个视频}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享带有文本的文件}other{正在分享带有文本的 # 个文件}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享带有链接的文件}other{正在分享带有链接的 # 个文件}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"分享相册"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{仅限图片}other{仅限图片}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{仅限视频}other{仅限视频}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{仅限文件}other{仅限文件}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此应用未获得录音权限,但能通过此 USB 设备录制音频。"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"个人"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"私密"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"个人视图"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作视图"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私密视图"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被 IT 管理员禁止"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"无法使用工作应用分享该内容"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"无法使用工作应用打开该内容"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"无法使用个人应用分享该内容"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"无法使用个人应用打开该内容"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"无法使用私人应用分享该内容"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"无法使用私人应用打开该内容"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作应用已暂停"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"解除暂停"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"没有支持该内容的工作应用"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"没有支持该内容的个人应用"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"无可用的专用应用"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要使用个人资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"要使用工作资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用个人浏览器"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"包括文本"</string>
<string name="exclude_link" msgid="1332778255031992228">"排除链接"</string>
<string name="include_link" msgid="827855767220339802">"包括链接"</string>
+ <string name="pinned" msgid="7623664001331394139">"已固定"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"可选择的图片"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"可选择的视频"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"可选择的内容"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"按钮"</string>
</resources>
diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml
index c48dc430..b3aed885 100644
--- a/java/res/values-zh-rHK/strings.xml
+++ b/java/res/values-zh-rHK/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{分享圖片}other{分享 # 張圖片}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{正在分享影片}other{正在分享 # 部影片}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{正在分享 # 個檔案}other{正在分享 # 個檔案}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"選取要分享的項目"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{正在分享圖片 (含有文字)}other{正在分享 # 張圖片 (含有文字)}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{正在分享圖片 (含有連結)}other{正在分享 # 張圖片 (含有連結)}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{正在分享影片 (含有文字)}other{正在分享 # 部影片 (含有文字)}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享影片 (含有連結)}other{正在分享 # 部影片 (含有連結)}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享檔案 (含有文字)}other{正在分享 # 個檔案 (含有文字)}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享檔案 (含有連結)}other{正在分享 # 個檔案 (含有連結)}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{僅含圖片}other{僅含圖片}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{僅含影片}other{僅含影片}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{僅含檔案}other{僅含檔案}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此應用程式尚未獲授予錄音權限,但可透過此 USB 裝置記錄音訊。"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被你的 IT 管理員封鎖"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法使用工作應用程式分享此內容"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟此內容"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享此內容"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟此內容"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法使用私人應用程式分享此內容"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟此內容"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"已暫停工作應用程式"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"沒有私人應用程式"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"加入文字"</string>
<string name="exclude_link" msgid="1332778255031992228">"不包括連結"</string>
<string name="include_link" msgid="827855767220339802">"加入連結"</string>
+ <string name="pinned" msgid="7623664001331394139">"固定咗"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"可以揀嘅圖片"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"可以揀嘅影片"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"可以揀嘅項目"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"按鈕"</string>
</resources>
diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml
index 96ff900b..97770baf 100644
--- a/java/res/values-zh-rTW/strings.xml
+++ b/java/res/values-zh-rTW/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{分享圖片}other{分享 # 張圖片}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{正在分享影片}other{正在分享 # 部影片}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{正在分享 # 個檔案}other{正在分享 # 個檔案}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"選取要分享的項目"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{分享含有文字的圖片}other{分享 # 張含有文字的圖片}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{分享含有連結的圖片}other{分享 # 張含有連結的圖片}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{分享含有文字的影片}other{分享 # 部含有文字的影片}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{分享含有連結的影片}other{分享 # 部含有連結的影片}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{分享含有文字的檔案}other{分享含有文字的 # 個檔案}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{分享含有連結的檔案}other{分享含有連結的 # 個檔案}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{只有圖片}other{只有圖片}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{只有影片}other{只有影片}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{只有檔案}other{只有檔案}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"這個應用程式未取得錄製內容的權限,但可以透過這部 USB 裝置錄製音訊。"</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理員已封鎖這項操作"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法透過工作應用程式分享這項內容"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟這項內容"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享這項內容"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟這項內容"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法透過私人應用程式分享這項內容"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟這項內容"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作應用程式目前為暫停狀態"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"私人應用程式不支援這項功能"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"加回文字"</string>
<string name="exclude_link" msgid="1332778255031992228">"排除連結"</string>
<string name="include_link" msgid="827855767220339802">"加回連結"</string>
+ <string name="pinned" msgid="7623664001331394139">"已固定"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"可選取的圖片"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"可選取的影片"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"可選取的項目"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"按鈕"</string>
</resources>
diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml
index a6feae36..bdf42d69 100644
--- a/java/res/values-zu/strings.xml
+++ b/java/res/values-zu/strings.xml
@@ -60,12 +60,14 @@
<string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Yabelana ngomfanekiso}one{Yabelana ngemifanekiso engu-#}other{Yabelana ngemifanekiso engu-#}}"</string>
<string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Yabelana ngevidiyo}one{Yabelana ngamavidiyo angu-#}other{Yabelana ngamavidiyo angu-#}}"</string>
<string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Yabelana ngefayela eli-#}one{Yabelana ngamafayela angu-#}other{Yabelana ngamafayela angu-#}}"</string>
+ <string name="select_items_to_share" msgid="1026071777275022579">"Khetha izinto ongabelana ngazo"</string>
<string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Yabelana ngomfanekiso ngombhalo}one{Yabelana ngemifanekiso engu-# ngombhalo}other{Yabelana ngemifanekiso engu-# ngombhalo}}"</string>
<string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Yabelana ngomfanekiso ngelinki}one{Yabelana ngemifanekiso engu-# ngelinki}other{Yabelana ngemifanekiso engu-# ngelinki}}"</string>
<string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Yabelana ngevidiyo ngombhalo}one{Yabelana ngamavidiyo angu-# ngombhalo}other{Yabelana ngamavidiyo angu-# ngombhalo}}"</string>
<string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Yabelana ngevidiyo ngelinki}one{Yabelana ngamavidiyo angu-# ngelinki}other{Yabelana ngamavidiyo angu-# ngelinki}}"</string>
<string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Yabelana ngefayela ngombhalo}one{Yabelana ngamafayela angu-# ngombhalo}other{Yabelana ngamafayela angu-# ngombhalo}}"</string>
<string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Yabelana ngefayela ngelinki}one{Yabelana ngamafayela angu-# ngelinki}other{Yabelana ngamafayela angu-# ngelinki}}"</string>
+ <string name="sharing_album" msgid="191743129899503345">"I-albhamu eyabiwe"</string>
<string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Isithombe kuphela}one{Izithombe kuphela}other{Izithombe kuphela}}"</string>
<string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ividiyo kuphela}one{Amavidiyo kuphela}other{Amavidiyo kuphela}}"</string>
<string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ifayela kuphela}one{Amafayela kuphela}other{Amafayela kuphela}}"</string>
@@ -76,17 +78,22 @@
<string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Lolu hlelo lokusebenza alunikeziwe imvume yokurekhoda kodwa lungathwebula umsindo ngale divayisi ye-USB."</string>
<string name="resolver_personal_tab" msgid="1381052735324320565">"Okomuntu siqu"</string>
<string name="resolver_work_tab" msgid="3588325717455216412">"Umsebenzi"</string>
+ <string name="resolver_private_tab" msgid="3707548826254095157">"Okuyimfihlo"</string>
<string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ukubuka komuntu siqu"</string>
<string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ukubuka komsebenzi"</string>
+ <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ukubuka okuyimfihlo"</string>
<string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Kuvinjelwe umlawuli wakho we-IT"</string>
<string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womsebenzi"</string>
<string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womsebenzi"</string>
<string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womuntu siqu"</string>
<string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womuntu siqu"</string>
+ <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Lokhu okuqukethwe akukwazi ukwabiwa ngama-app agodliwe"</string>
+ <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Lokhu okuqukethwe akukwazi ukuvulwa ngama-app agodliwe"</string>
<string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ama-app omsebenzi amisiwe"</string>
<string name="resolver_switch_on_work" msgid="8678893259344318807">"Qhubekisa"</string>
<string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Awekho ama-app womsebenzi"</string>
<string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Awekho ama-app womuntu siqu"</string>
+ <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Awekho ama-app ayimfihlo"</string>
<string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho siqu?"</string>
<string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho yomsebenzi?"</string>
<string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Sebenzisa isiphequluli somuntu siqu"</string>
@@ -95,4 +102,9 @@
<string name="include_text" msgid="642280283268536140">"Faka umbhalo"</string>
<string name="exclude_link" msgid="1332778255031992228">"Ungafaki ilinki"</string>
<string name="include_link" msgid="827855767220339802">"Faka ilinki"</string>
+ <string name="pinned" msgid="7623664001331394139">"Kuphiniwe"</string>
+ <string name="selectable_image" msgid="3157858923437182271">"Umfanekiso okhethekayo"</string>
+ <string name="selectable_video" msgid="1271768647699300826">"Ividiyo ekhethekayo"</string>
+ <string name="selectable_item" msgid="7557320816744205280">"Into ekhethekayo"</string>
+ <string name="role_description_button" msgid="4537198530568333649">"Inkinobho"</string>
</resources>
diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml
index 67acb3ae..19d85573 100644
--- a/java/res/values/attrs.xml
+++ b/java/res/values/attrs.xml
@@ -32,6 +32,11 @@
will push all ignoreOffset siblings below it when the drawer is moved i.e. setting the
top limit the ignoreOffset elements. -->
<attr name="ignoreOffsetTopLimit" format="reference" />
+ <!-- Specifies whether ResolverDrawerLayout should use an alternative nested fling logic
+ adjusted for the scrollable preview feature.
+ Controlled by the flag com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW.
+ -->
+ <attr name="useScrollablePreviewNestedFlingLogic" format="boolean" />
</declare-styleable>
<declare-styleable name="ResolverDrawerLayout_LayoutParams">
@@ -50,5 +55,13 @@
<attr name="itemInnerSpacing" format="dimension" />
<attr name="itemOuterSpacing" format="dimension" />
<attr name="maxWidthHint" format="dimension" />
+ <attr name="editButtonRoleDescription" format="string" />
+ </declare-styleable>
+
+ <declare-styleable name="ChooserTargetItemView">
+ <attr name="focusOutlineColor" format="color" />
+ <attr name="focusInnerOutlineColor" format="color" />
+ <attr name="focusOutlineWidth" format="dimension" />
+ <attr name="focusOutlineCornerRadius" format="dimension" />
</declare-styleable>
</resources>
diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml
index ae80815b..515343b6 100644
--- a/java/res/values/dimens.xml
+++ b/java/res/values/dimens.xml
@@ -19,7 +19,6 @@
<!-- chooser/resolver (sharesheet) spacing -->
<dimen name="chooser_action_corner_radius">28dp</dimen>
<dimen name="chooser_action_horizontal_margin">2dp</dimen>
- <dimen name="chooser_action_max_width">200dp</dimen>
<dimen name="chooser_width">450dp</dimen>
<dimen name="chooser_corner_radius">28dp</dimen>
<dimen name="chooser_corner_radius_small">14dp</dimen>
@@ -33,9 +32,17 @@
<dimen name="chooser_preview_image_max_dimen">200dp</dimen>
<dimen name="chooser_header_scroll_elevation">4dp</dimen>
<dimen name="chooser_max_collapsed_height">288dp</dimen>
- <dimen name="chooser_direct_share_label_placeholder_max_width">72dp</dimen>
<dimen name="chooser_icon_size">56dp</dimen>
<dimen name="chooser_badge_size">22dp</dimen>
+ <dimen name="chooser_icon_horizontal_padding">8dp</dimen>
+ <dimen name="chooser_icon_vertical_padding">7dp</dimen>
+ <dimen name="chooser_icon_width_with_padding">72dp</dimen> <!-- = chooser_icon_size + chooser_icon_horizontal_padding * 2 -->
+ <dimen name="chooser_icon_height_with_padding">70dp</dimen> <!-- = chooser_icon_size + chooser_icon_vertical_padding * 2 -->
+ <dimen name="chooser_headline_text_size">18sp</dimen>
+ <dimen name="chooser_grid_target_name_text_size">12sp</dimen>
+ <dimen name="chooser_grid_activity_name_text_size">12sp</dimen>
+ <dimen name="chooser_item_focus_outline_corner_radius">11dp</dimen>
+ <dimen name="chooser_item_focus_outline_width">2dp</dimen>
<dimen name="resolver_icon_size">32dp</dimen>
<dimen name="resolver_button_bar_spacing">0dp</dimen>
<dimen name="resolver_badge_size">18dp</dimen>
@@ -53,6 +60,7 @@
<dimen name="resolver_empty_state_container_padding_bottom">8dp</dimen>
<dimen name="resolver_profile_tab_margin">4dp</dimen>
<dimen name="chooser_action_view_icon_size">22dp</dimen>
+ <dimen name="chooser_action_view_text_size">12sp</dimen>
<dimen name="chooser_action_margin">0dp</dimen>
<dimen name="modify_share_text_toggle_max_width">150dp</dimen>
<dimen name="chooser_view_spacing">16dp</dimen>
@@ -60,7 +68,7 @@
<!-- Note that the values in this section are for landscape phones. For screen configs taller
than 480dp, the values are set in values-h480dp/dimens.xml -->
<dimen name="chooser_preview_width">412dp</dimen>
- <dimen name="chooser_preview_image_height_tall">46dp</dimen>
+ <dimen name="chooser_preview_image_height_tall">124dp</dimen>
<dimen name="grid_padding">8dp</dimen>
<dimen name="width_text_image_preview_size">46dp</dimen>
<!-- END SECTION -->
diff --git a/java/res/values/integers.xml b/java/res/values/integers.xml
index 6d57e43e..8d203bca 100644
--- a/java/res/values/integers.xml
+++ b/java/res/values/integers.xml
@@ -17,5 +17,5 @@
<resources>
<!-- Note that this is the value for landscape phones, the value for all screens taller than
480dp is set in values-h480dp/integers.xml -->
- <integer name="text_preview_lines">1</integer>
+ <integer name="text_preview_lines">3</integer>
</resources>
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index 4b5367c0..6504462f 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -162,6 +162,9 @@
}
</string>
+ <!-- Title atop a sharing UI indicating that a selection needs to be made for sharing -->
+ <string name="select_items_to_share">Select items to share</string>
+
<!-- Title atop a sharing UI indicating that some number of images are being shared
along with text [CHAR_LIMIT=50] -->
<string name="sharing_images_with_text">{count, plural,
@@ -210,6 +213,11 @@
}
</string>
+
+ <!-- Title atop a sharing UI indicating that an album (typically of photos/videos) is being
+ shared [CHAR_LIMIT=50] -->
+ <string name="sharing_album">Sharing album</string>
+
<!-- Message indicating that the attached text has been removed from this share and only the
images are being shared. [CHAR LIMIT=none] -->
<string name="sharing_images_only">{count, plural,
@@ -250,16 +258,20 @@
<!-- Prompt for the USB device resolver dialog with warning text for USB device dialogs. [CHAR LIMIT=200] -->
<string name="usb_device_resolve_prompt_warn">This app has not been granted record permission but could capture audio through this USB device.</string>
- <!-- ResolverActivity - profile tabs -->
+ <!-- ChooserActivity + ResolverActivity - profile tabs -->
<!-- Label of a tab on a screen. A user can tap this tap to switch to the 'Personal' view (that shows their personal content) if they have a work profile on their device. [CHAR LIMIT=NONE] -->
<string name="resolver_personal_tab">Personal</string>
<!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Work' view (that shows their work content) if they have a work profile on their device. [CHAR LIMIT=NONE] -->
<string name="resolver_work_tab">Work</string>
+ <!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Private' view (that shows their Private Space content) if they have private space configured on their device. [CHAR LIMIT=NONE] -->
+ <string name="resolver_private_tab">Private</string>
<!-- Accessibility label for the personal tab button. [CHAR LIMIT=NONE] -->
<string name="resolver_personal_tab_accessibility">Personal view</string>
<!-- Accessibility label for the work tab button. [CHAR LIMIT=NONE] -->
<string name="resolver_work_tab_accessibility">Work view</string>
+ <!-- Accessibility label for the private tab button. [CHAR LIMIT=NONE] -->
+ <string name="resolver_private_tab_accessibility">Private view</string>
<!-- Title of a screen. This text lets the user know that their IT admin doesn't allow them to share this content across profiles. [CHAR LIMIT=NONE] -->
<string name="resolver_cross_profile_blocked">Blocked by your IT admin</string>
@@ -275,6 +287,12 @@
<!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their personal profile. [CHAR LIMIT=NONE] -->
<string name="resolver_cant_access_personal_apps_explanation">This content can\u2019t be opened with personal apps</string>
+ <!-- Error message. This text is explaining that the user's IT admin doesn't allow this specific content to be shared with apps in the private profile. [CHAR LIMIT=NONE] -->
+ <string name="resolver_cant_share_with_private_apps_explanation">This content can\u2019t be shared with private apps</string>
+
+ <!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their private profile. [CHAR LIMIT=NONE] -->
+ <string name="resolver_cant_access_private_apps_explanation">This content can\u2019t be opened with private apps</string>
+
<!-- Error message. This text lets the user know that they need to turn on work apps in order to share or open content. There's also a button a user can tap to turn on the apps. [CHAR LIMIT=NONE] -->
<string name="resolver_turn_on_work_apps">Work apps are paused</string>
<!-- Button text. This button unpauses a user's work apps and data. [CHAR LIMIT=NONE] -->
@@ -286,6 +304,9 @@
<!-- Error message. This text lets the user know that their current personal apps don't support the specific content. [CHAR LIMIT=NONE] -->
<string name="resolver_no_personal_apps_available">No personal apps</string>
+ <!-- Error message. This text lets the user know that their current private apps don't support the specific content. [CHAR LIMIT=NONE] -->
+ <string name="resolver_no_private_apps_available">No private apps</string>
+
<!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] -->
<string name="miniresolver_open_in_personal">Open <xliff:g id="app" example="YouTube">%s</xliff:g> in your personal profile?</string>
<!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] -->
@@ -303,4 +324,30 @@
<string name="exclude_link">Exclude link</string>
<!-- Title for a button. Adds back a (previously excluded) web link into the shared content. -->
<string name="include_link">Include link</string>
+
+ <!-- Accessibility content description for a sharesheet target that has been pinned to the
+ front of the list by the user. [CHAR LIMIT=NONE] -->
+ <string name="pinned">Pinned</string>
+
+ <!-- Accessibility content description for an image that the user may select for sharing.
+ [CHAR LIMIT=NONE] -->
+ <string name="selectable_image">Selectable image</string>
+ <!-- Accessibility content description for a video that the user may select for sharing.
+ [CHAR LIMIT=NONE] -->
+ <string name="selectable_video">Selectable video</string>
+ <!-- Accessibility content description for an item that the user may select for sharing.
+ [CHAR LIMIT=NONE] -->
+ <string name="selectable_item">Selectable item</string>
+ <!-- Accessibility role description for a11y on button. [CHAR LIMIT=NONE] -->
+ <string name="role_description_button">Button</string>
+
+ <!-- Accessibility announcement for the shortcut group (https://developer.android.com/training/sharing/direct-share-targets)
+ in the list of targets. [CHAR LIMIT=NONE]-->
+ <string name="shortcut_group_a11y_title">Direct share targets</string>
+ <!-- Accessibility announcement for the suggested application group in the list of targets.
+ [CHAR LIMIT=NONE] -->
+ <string name="suggested_apps_group_a11y_title">App suggestions</string>
+ <!-- Accessibility announcement for the all-applications group in the list of targets.
+ [CHAR LIMIT=NONE] -->
+ <string name="all_apps_group_a11y_title">App list</string>
</resources>
diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml
index 0ccab4c0..143009d0 100644
--- a/java/res/values/styles.xml
+++ b/java/res/values/styles.xml
@@ -45,7 +45,7 @@
<style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver">
<item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item>
<item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item>
- <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ <item name="android:windowLayoutInDisplayCutoutMode">always</item>
</style>
<style name="TextAppearance.ChooserDefault"
diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt
deleted file mode 100644
index 5067c0ee..00000000
--- a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver.flags
-
-import android.util.SparseBooleanArray
-import androidx.annotation.GuardedBy
-import com.android.systemui.flags.BooleanFlag
-import com.android.systemui.flags.FlagManager
-import com.android.systemui.flags.ReleasedFlag
-import com.android.systemui.flags.UnreleasedFlag
-import javax.annotation.concurrent.ThreadSafe
-
-@ThreadSafe
-internal class DebugFeatureFlagRepository(
- private val flagManager: FlagManager,
- private val deviceConfig: DeviceConfigProxy,
-) : FeatureFlagRepository {
- @GuardedBy("self")
- private val cache = hashMapOf<String, Boolean>()
-
- override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag)
-
- override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag)
-
- private fun isFlagEnabled(flag: BooleanFlag): Boolean {
- synchronized(cache) {
- cache[flag.name]?.let { return it }
- }
- val flagValue = readFlagValue(flag)
- return synchronized(cache) {
- // the first read saved in the cache wins
- cache.getOrPut(flag.name) { flagValue }
- }
- }
-
- private fun readFlagValue(flag: BooleanFlag): Boolean {
- val localOverride = runCatching {
- flagManager.isEnabled(flag.name)
- }.getOrDefault(null)
- val remoteOverride = deviceConfig.isEnabled(flag)
-
- // Only check for teamfood if the default is false
- // and there is no server override.
- if (remoteOverride == null
- && !flag.default
- && localOverride == null
- && !flag.isTeamfoodFlag
- && flag.teamfood
- ) {
- return flagManager.isTeamfoodEnabled
- }
- return localOverride ?: remoteOverride ?: flag.default
- }
-
- companion object {
- /** keep in sync with [com.android.systemui.flags.Flags] */
- private const val TEAMFOOD_FLAG_NAME = "teamfood"
-
- private val BooleanFlag.isTeamfoodFlag: Boolean
- get() = name == TEAMFOOD_FLAG_NAME
-
- private val FlagManager.isTeamfoodEnabled: Boolean
- get() = runCatching {
- isEnabled(TEAMFOOD_FLAG_NAME) ?: false
- }.getOrDefault(false)
- }
-}
diff --git a/java/src/android/service/chooser/ChooserSession.kt b/java/src/android/service/chooser/ChooserSession.kt
new file mode 100644
index 00000000..3bbe23a4
--- /dev/null
+++ b/java/src/android/service/chooser/ChooserSession.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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 android.service.chooser
+
+import android.os.Parcel
+import android.os.Parcelable
+import com.android.intentresolver.IChooserInteractiveSessionCallback
+
+/** A stub for the potential future API class. */
+class ChooserSession(val sessionCallbackBinder: IChooserInteractiveSessionCallback) : Parcelable {
+ override fun describeContents() = 0
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ TODO("Not yet implemented")
+ }
+
+ companion object CREATOR : Parcelable.Creator<ChooserSession> {
+ override fun createFromParcel(source: Parcel): ChooserSession? =
+ ChooserSession(
+ IChooserInteractiveSessionCallback.Stub.asInterface(source.readStrongBinder())
+ )
+
+ override fun newArray(size: Int): Array<out ChooserSession?> = arrayOfNulls(size)
+ }
+}
diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
deleted file mode 100644
index 4b06db3b..00000000
--- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
+++ /dev/null
@@ -1,582 +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.intentresolver;
-
-import android.annotation.IntDef;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.annotation.UserIdInt;
-import android.app.AppGlobals;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.IPackageManager;
-import android.os.Trace;
-import android.os.UserHandle;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.TextView;
-
-import androidx.viewpager.widget.PagerAdapter;
-import androidx.viewpager.widget.ViewPager;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-import java.util.HashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import java.util.function.Supplier;
-
-/**
- * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for
- * intent resolution (including share sheet).
- */
-public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter {
-
- private static final String TAG = "AbstractMultiProfilePagerAdapter";
- static final int PROFILE_PERSONAL = 0;
- static final int PROFILE_WORK = 1;
-
- @IntDef({PROFILE_PERSONAL, PROFILE_WORK})
- @interface Profile {}
-
- private final Context mContext;
- private int mCurrentPage;
- private OnProfileSelectedListener mOnProfileSelectedListener;
-
- private Set<Integer> mLoadedPages;
- private final EmptyStateProvider mEmptyStateProvider;
- private final UserHandle mWorkProfileUserHandle;
- private final UserHandle mCloneProfileUserHandle;
- private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet.
-
- AbstractMultiProfilePagerAdapter(
- Context context,
- int currentPage,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle) {
- mContext = Objects.requireNonNull(context);
- mCurrentPage = currentPage;
- mLoadedPages = new HashSet<>();
- mWorkProfileUserHandle = workProfileUserHandle;
- mCloneProfileUserHandle = cloneProfileUserHandle;
- mEmptyStateProvider = emptyStateProvider;
- mWorkProfileQuietModeChecker = workProfileQuietModeChecker;
- }
-
- void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
- mOnProfileSelectedListener = listener;
- }
-
- Context getContext() {
- return mContext;
- }
-
- /**
- * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets
- * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed
- * page and rebuilds the list.
- */
- void setupViewPager(ViewPager viewPager) {
- viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
- @Override
- public void onPageSelected(int position) {
- mCurrentPage = position;
- if (!mLoadedPages.contains(position)) {
- rebuildActiveTab(true);
- mLoadedPages.add(position);
- }
- if (mOnProfileSelectedListener != null) {
- mOnProfileSelectedListener.onProfileSelected(position);
- }
- }
-
- @Override
- public void onPageScrollStateChanged(int state) {
- if (mOnProfileSelectedListener != null) {
- mOnProfileSelectedListener.onProfilePageStateChanged(state);
- }
- }
- });
- viewPager.setAdapter(this);
- viewPager.setCurrentItem(mCurrentPage);
- mLoadedPages.add(mCurrentPage);
- }
-
- void clearInactiveProfileCache() {
- if (mLoadedPages.size() == 1) {
- return;
- }
- mLoadedPages.remove(1 - mCurrentPage);
- }
-
- @Override
- public ViewGroup instantiateItem(ViewGroup container, int position) {
- final ProfileDescriptor profileDescriptor = getItem(position);
- container.addView(profileDescriptor.rootView);
- return profileDescriptor.rootView;
- }
-
- @Override
- public void destroyItem(ViewGroup container, int position, Object view) {
- container.removeView((View) view);
- }
-
- @Override
- public int getCount() {
- return getItemCount();
- }
-
- protected int getCurrentPage() {
- return mCurrentPage;
- }
-
- @VisibleForTesting
- public UserHandle getCurrentUserHandle() {
- return getActiveListAdapter().getUserHandle();
- }
-
- @Override
- public boolean isViewFromObject(View view, Object object) {
- return view == object;
- }
-
- @Override
- public CharSequence getPageTitle(int position) {
- return null;
- }
-
- public UserHandle getCloneUserHandle() {
- return mCloneProfileUserHandle;
- }
-
- /**
- * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>.
- * <ul>
- * <li>For a device with only one user, <code>pageIndex</code> value of
- * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li>
- * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would
- * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of
- * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li>
- * </ul>
- */
- abstract ProfileDescriptor getItem(int pageIndex);
-
- protected ViewGroup getEmptyStateView(int pageIndex) {
- return getItem(pageIndex).getEmptyStateView();
- }
-
- /**
- * Returns the number of {@link ProfileDescriptor} objects.
- * <p>For a normal consumer device with only one user returns <code>1</code>.
- * <p>For a device with a work profile returns <code>2</code>.
- */
- abstract int getItemCount();
-
- /**
- * Performs view-related initialization procedures for the adapter specified
- * by <code>pageIndex</code>.
- */
- abstract void setupListAdapter(int pageIndex);
-
- /**
- * Returns the adapter of the list view for the relevant page specified by
- * <code>pageIndex</code>.
- * <p>This method is meant to be implemented with an implementation-specific return type
- * depending on the adapter type.
- */
- @VisibleForTesting
- public abstract Object getAdapterForIndex(int pageIndex);
-
- /**
- * Returns the {@link ResolverListAdapter} instance of the profile that represents
- * <code>userHandle</code>. If there is no such adapter for the specified
- * <code>userHandle</code>, returns {@code null}.
- * <p>For example, if there is a work profile on the device with user id 10, calling this method
- * with <code>UserHandle.of(10)</code> returns the work profile {@link ResolverListAdapter}.
- */
- @Nullable
- abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle);
-
- /**
- * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible
- * to the user.
- * <p>For example, if the user is viewing the work tab in the share sheet, this method returns
- * the work profile {@link ResolverListAdapter}.
- * @see #getInactiveListAdapter()
- */
- @VisibleForTesting
- public abstract ResolverListAdapter getActiveListAdapter();
-
- /**
- * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance
- * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns
- * {@code null}.
- * <p>For example, if the user is viewing the work tab in the share sheet, this method returns
- * the personal profile {@link ResolverListAdapter}.
- * @see #getActiveListAdapter()
- */
- @VisibleForTesting
- public abstract @Nullable ResolverListAdapter getInactiveListAdapter();
-
- public abstract ResolverListAdapter getPersonalListAdapter();
-
- public abstract @Nullable ResolverListAdapter getWorkListAdapter();
-
- abstract Object getCurrentRootAdapter();
-
- abstract ViewGroup getActiveAdapterView();
-
- abstract @Nullable ViewGroup getInactiveAdapterView();
-
- /**
- * Rebuilds the tab that is currently visible to the user.
- * <p>Returns {@code true} if rebuild has completed.
- */
- boolean rebuildActiveTab(boolean doPostProcessing) {
- Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab");
- boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing);
- Trace.endSection();
- return result;
- }
-
- /**
- * Rebuilds the tab that is not currently visible to the user, if such one exists.
- * <p>Returns {@code true} if rebuild has completed.
- */
- boolean rebuildInactiveTab(boolean doPostProcessing) {
- Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab");
- if (getItemCount() == 1) {
- Trace.endSection();
- return false;
- }
- boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing);
- Trace.endSection();
- return result;
- }
-
- private int userHandleToPageIndex(UserHandle userHandle) {
- if (userHandle.equals(getPersonalListAdapter().getUserHandle())) {
- return PROFILE_PERSONAL;
- } else {
- return PROFILE_WORK;
- }
- }
-
- private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) {
- if (shouldSkipRebuild(activeListAdapter)) {
- activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true);
- return false;
- }
- return activeListAdapter.rebuildList(doPostProcessing);
- }
-
- private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) {
- EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter);
- return emptyState != null && emptyState.shouldSkipDataRebuild();
- }
-
- /**
- * The empty state screens are shown according to their priority:
- * <ol>
- * <li>(highest priority) cross-profile disabled by policy (handled in
- * {@link #rebuildTab(ResolverListAdapter, boolean)})</li>
- * <li>no apps available</li>
- * <li>(least priority) work is off</li>
- * </ol>
- *
- * The intention is to prevent the user from having to turn
- * the work profile on if there will not be any apps resolved
- * anyway.
- */
- void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) {
- final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter);
-
- if (emptyState == null) {
- return;
- }
-
- emptyState.onEmptyStateShown();
-
- View.OnClickListener clickListener = null;
-
- if (emptyState.getButtonClickListener() != null) {
- clickListener = v -> emptyState.getButtonClickListener().onClick(() -> {
- ProfileDescriptor descriptor = getItem(
- userHandleToPageIndex(listAdapter.getUserHandle()));
- AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView());
- });
- }
-
- showEmptyState(listAdapter, emptyState, clickListener);
- }
-
- /**
- * Class to get user id of the current process
- */
- public static class MyUserIdProvider {
- /**
- * @return user id of the current process
- */
- public int getMyUserId() {
- return UserHandle.myUserId();
- }
- }
-
- /**
- * Utility class to check if there are cross profile intents, it is in a separate class so
- * it could be mocked in tests
- */
- public static class CrossProfileIntentsChecker {
-
- private final ContentResolver mContentResolver;
-
- public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) {
- mContentResolver = contentResolver;
- }
-
- /**
- * Returns {@code true} if at least one of the provided {@code intents} can be forwarded
- * from {@code source} (user id) to {@code target} (user id).
- */
- public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source,
- @UserIdInt int target) {
- IPackageManager packageManager = AppGlobals.getPackageManager();
-
- return intents.stream().anyMatch(intent ->
- null != IntentForwarderActivity.canForward(intent, source, target,
- packageManager, mContentResolver));
- }
- }
-
- protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState,
- View.OnClickListener buttonOnClick) {
- ProfileDescriptor descriptor = getItem(
- userHandleToPageIndex(activeListAdapter.getUserHandle()));
- descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE);
- ViewGroup emptyStateView = descriptor.getEmptyStateView();
- resetViewVisibilitiesForEmptyState(emptyStateView);
- emptyStateView.setVisibility(View.VISIBLE);
-
- View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container);
- setupContainerPadding(container);
-
- TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title);
- String title = emptyState.getTitle();
- if (title != null) {
- titleView.setVisibility(View.VISIBLE);
- titleView.setText(title);
- } else {
- titleView.setVisibility(View.GONE);
- }
-
- TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle);
- String subtitle = emptyState.getSubtitle();
- if (subtitle != null) {
- subtitleView.setVisibility(View.VISIBLE);
- subtitleView.setText(subtitle);
- } else {
- subtitleView.setVisibility(View.GONE);
- }
-
- View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty);
- defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE);
-
- Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button);
- button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE);
- button.setOnClickListener(buttonOnClick);
-
- activeListAdapter.markTabLoaded();
- }
-
- /**
- * Sets up the padding of the view containing the empty state screens.
- * <p>This method is meant to be overridden so that subclasses can customize the padding.
- */
- protected void setupContainerPadding(View container) {}
-
- private void showSpinner(View emptyStateView) {
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE);
- }
-
- private void resetViewVisibilitiesForEmptyState(View emptyStateView) {
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE);
- emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE);
- }
-
- protected void showListView(ResolverListAdapter activeListAdapter) {
- ProfileDescriptor descriptor = getItem(
- userHandleToPageIndex(activeListAdapter.getUserHandle()));
- descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE);
- View emptyStateView = descriptor.rootView.findViewById(com.android.internal.R.id.resolver_empty_state);
- emptyStateView.setVisibility(View.GONE);
- }
-
- boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) {
- int count = listAdapter.getUnfilteredCount();
- return (count == 0 && listAdapter.getPlaceholderCount() == 0)
- || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
- && mWorkProfileQuietModeChecker.get());
- }
-
- protected static class ProfileDescriptor {
- final ViewGroup rootView;
- private final ViewGroup mEmptyStateView;
- ProfileDescriptor(ViewGroup rootView) {
- this.rootView = rootView;
- mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state);
- }
-
- protected ViewGroup getEmptyStateView() {
- return mEmptyStateView;
- }
- }
-
- public interface OnProfileSelectedListener {
- /**
- * Callback for when the user changes the active tab from personal to work or vice versa.
- * <p>This callback is only called when the intent resolver or share sheet shows
- * the work and personal profiles.
- * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or
- * {@link #PROFILE_WORK} if the work profile was selected.
- */
- void onProfileSelected(int profileIndex);
-
-
- /**
- * Callback for when the scroll state changes. Useful for discovering when the user begins
- * dragging, when the pager is automatically settling to the current page, or when it is
- * fully stopped/idle.
- * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING}
- * or {@link ViewPager#SCROLL_STATE_SETTLING}
- * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged
- */
- void onProfilePageStateChanged(int state);
- }
-
- /**
- * Returns an empty state to show for the current profile page (tab) if necessary.
- * This could be used e.g. to show a blocker on a tab if device management policy doesn't
- * allow to use it or there are no apps available.
- */
- public interface EmptyStateProvider {
- /**
- * When a non-null empty state is returned the corresponding profile page will show
- * this empty state
- * @param resolverListAdapter the current adapter
- */
- @Nullable
- default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- return null;
- }
- }
-
- /**
- * Empty state provider that combines multiple providers. Providers earlier in the list have
- * priority, that is if there is a provider that returns non-null empty state then all further
- * providers will be ignored.
- */
- public static class CompositeEmptyStateProvider implements EmptyStateProvider {
-
- private final EmptyStateProvider[] mProviders;
-
- public CompositeEmptyStateProvider(EmptyStateProvider... providers) {
- mProviders = providers;
- }
-
- @Nullable
- @Override
- public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- for (EmptyStateProvider provider : mProviders) {
- EmptyState emptyState = provider.getEmptyState(resolverListAdapter);
- if (emptyState != null) {
- return emptyState;
- }
- }
- return null;
- }
- }
-
- /**
- * Describes how the blocked empty state should look like for a profile tab
- */
- public interface EmptyState {
- /**
- * Title that will be shown on the empty state
- */
- @Nullable
- default String getTitle() { return null; }
-
- /**
- * Subtitle that will be shown underneath the title on the empty state
- */
- @Nullable
- default String getSubtitle() { return null; }
-
- /**
- * If non-null then a button will be shown and this listener will be called
- * when the button is clicked
- */
- @Nullable
- default ClickListener getButtonClickListener() { return null; }
-
- /**
- * If true then default text ('No apps can perform this action') and style for the empty
- * state will be applied, title and subtitle will be ignored.
- */
- default boolean useDefaultEmptyView() { return false; }
-
- /**
- * Returns true if for this empty state we should skip rebuilding of the apps list
- * for this tab.
- */
- default boolean shouldSkipDataRebuild() { return false; }
-
- /**
- * Called when empty state is shown, could be used e.g. to track analytics events
- */
- default void onEmptyStateShown() {}
-
- interface ClickListener {
- void onClick(TabControl currentTab);
- }
-
- interface TabControl {
- void showSpinner();
- }
- }
-
-
- /**
- * Listener for when the user switches on the work profile from the work tab.
- */
- interface OnSwitchOnWorkSelectedListener {
- /**
- * Callback for when the user switches on the work profile from the work tab.
- */
- void onSwitchOnWorkSelected();
- }
-}
diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java
deleted file mode 100644
index 168f36d6..00000000
--- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java
+++ /dev/null
@@ -1,217 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver;
-
-import android.annotation.Nullable;
-import android.app.Activity;
-import android.app.ActivityManager;
-import android.os.UserHandle;
-import android.os.UserManager;
-
-import androidx.annotation.VisibleForTesting;
-
-/**
- * Helper class to precompute the (immutable) designations of various user handles in the system
- * that may contribute to the current Sharesheet session.
- */
-public final class AnnotatedUserHandles {
- /** The user id of the app that started the share activity. */
- public final int userIdOfCallingApp;
-
- /**
- * The {@link UserHandle} that launched Sharesheet.
- * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp}
- * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch
- * Sharesheet as a different user than they themselves were running as. Verify and document.
- */
- public final UserHandle userHandleSharesheetLaunchedAs;
-
- /**
- * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab'
- * in a non-tabbed UI).
- *
- * This is never a work or clone user, but may either be the root user (0) or a "secondary"
- * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary"
- * profile only when that user is the active "foreground" user.
- *
- * In the current implementation, we can assert that this is the root user (0) any time we
- * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we
- * have a clone profile. This note is only provided for informational purposes; clients should
- * avoid making any reliances on that assumption.
- */
- public final UserHandle personalProfileUserHandle;
-
- /**
- * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary)
- * one of the "managed" profiles associated with {@link personalProfileUserHandle}.
- */
- @Nullable
- public final UserHandle workProfileUserHandle;
-
- /**
- * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}.
- */
- @Nullable
- public final UserHandle cloneProfileUserHandle;
-
- /**
- * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or
- * {@link workProfileUserHandle}) that either matches or owns the profile of the
- * {@link userHandleSharesheetLaunchedAs}.
- *
- * In the current implementation, we can assert that this is the same as
- * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is
- * the "personal" profile owning that clone profile (which we currently know must belong to
- * user 0, but clients should avoid making any reliances on that assumption).
- */
- public final UserHandle tabOwnerUserHandleForLaunch;
-
- /** Compute all handle designations for a new Sharesheet session in the specified activity. */
- public static AnnotatedUserHandles forShareActivity(Activity shareActivity) {
- // TODO: consider integrating logic for `ResolverActivity.EXTRA_CALLING_USER`?
- UserHandle userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId());
-
- // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work
- // profile is active, we always make the personal tab from the foreground user.
- // Outside profiles, current foreground user is potentially the same as the sharesheet
- // process's user (UserHandle.myUserId()), so we continue to create personal tab with the
- // current foreground user.
- UserHandle personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser());
-
- UserManager userManager = shareActivity.getSystemService(UserManager.class);
-
- return newBuilder()
- .setUserIdOfCallingApp(shareActivity.getLaunchedFromUid())
- .setUserHandleSharesheetLaunchedAs(userHandleSharesheetLaunchedAs)
- .setPersonalProfileUserHandle(personalProfileUserHandle)
- .setWorkProfileUserHandle(
- getWorkProfileForUser(userManager, personalProfileUserHandle))
- .setCloneProfileUserHandle(
- getCloneProfileForUser(userManager, personalProfileUserHandle))
- .build();
- }
-
- @VisibleForTesting static Builder newBuilder() {
- return new Builder();
- }
-
- /**
- * Returns the {@link UserHandle} to use when querying resolutions for intents in a
- * {@link ResolverListController} configured for the provided {@code userHandle}.
- */
- public UserHandle getQueryIntentsUser(UserHandle userHandle) {
- // In case launching app is in clonedProfile, and we are building the personal tab, intent
- // resolution will be attempted as clonedUser instead of user 0. This is because intent
- // resolution from user 0 and clonedUser is not guaranteed to return same results.
- // We do not care about the case when personal adapter is started with non-root user
- // (secondary user case), as clone profile is guaranteed to be non-active in that case.
- UserHandle queryIntentsUser = userHandle;
- if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) {
- queryIntentsUser = cloneProfileUserHandle;
- }
- return queryIntentsUser;
- }
-
- private Boolean isLaunchedAsCloneProfile() {
- return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle);
- }
-
- private AnnotatedUserHandles(
- int userIdOfCallingApp,
- UserHandle userHandleSharesheetLaunchedAs,
- UserHandle personalProfileUserHandle,
- @Nullable UserHandle workProfileUserHandle,
- @Nullable UserHandle cloneProfileUserHandle) {
- if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) {
- throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp);
- }
-
- this.userIdOfCallingApp = userIdOfCallingApp;
- this.userHandleSharesheetLaunchedAs = userHandleSharesheetLaunchedAs;
- this.personalProfileUserHandle = personalProfileUserHandle;
- this.workProfileUserHandle = workProfileUserHandle;
- this.cloneProfileUserHandle = cloneProfileUserHandle;
- this.tabOwnerUserHandleForLaunch =
- (userHandleSharesheetLaunchedAs == workProfileUserHandle)
- ? workProfileUserHandle : personalProfileUserHandle;
- }
-
- @Nullable
- private static UserHandle getWorkProfileForUser(
- UserManager userManager, UserHandle profileOwnerUserHandle) {
- return userManager.getProfiles(profileOwnerUserHandle.getIdentifier())
- .stream()
- .filter(info -> info.isManagedProfile())
- .findFirst()
- .map(info -> info.getUserHandle())
- .orElse(null);
- }
-
- @Nullable
- private static UserHandle getCloneProfileForUser(
- UserManager userManager, UserHandle profileOwnerUserHandle) {
- return userManager.getProfiles(profileOwnerUserHandle.getIdentifier())
- .stream()
- .filter(info -> info.isCloneProfile())
- .findFirst()
- .map(info -> info.getUserHandle())
- .orElse(null);
- }
-
- @VisibleForTesting
- static class Builder {
- private int mUserIdOfCallingApp;
- private UserHandle mUserHandleSharesheetLaunchedAs;
- private UserHandle mPersonalProfileUserHandle;
- private UserHandle mWorkProfileUserHandle;
- private UserHandle mCloneProfileUserHandle;
-
- public Builder setUserIdOfCallingApp(int id) {
- mUserIdOfCallingApp = id;
- return this;
- }
-
- public Builder setUserHandleSharesheetLaunchedAs(UserHandle user) {
- mUserHandleSharesheetLaunchedAs = user;
- return this;
- }
-
- public Builder setPersonalProfileUserHandle(UserHandle user) {
- mPersonalProfileUserHandle = user;
- return this;
- }
-
- public Builder setWorkProfileUserHandle(UserHandle user) {
- mWorkProfileUserHandle = user;
- return this;
- }
-
- public Builder setCloneProfileUserHandle(UserHandle user) {
- mCloneProfileUserHandle = user;
- return this;
- }
-
- public AnnotatedUserHandles build() {
- return new AnnotatedUserHandles(
- mUserIdOfCallingApp,
- mUserHandleSharesheetLaunchedAs,
- mPersonalProfileUserHandle,
- mWorkProfileUserHandle,
- mCloneProfileUserHandle);
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java
index a54e8c62..21ca3b73 100644
--- a/java/src/com/android/intentresolver/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -16,7 +16,8 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
+import static com.android.intentresolver.widget.ViewExtensionsKt.isFullyVisible;
+
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
@@ -34,10 +35,14 @@ import android.text.TextUtils;
import android.util.Log;
import android.view.View;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.ui.ShareResultSender;
+import com.android.intentresolver.ui.model.ShareAction;
import com.android.intentresolver.widget.ActionRow;
import com.android.internal.annotations.VisibleForTesting;
@@ -45,6 +50,7 @@ import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
@@ -52,8 +58,11 @@ import java.util.function.Consumer;
* Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application
* requirements of Sharesheet / {@link ChooserActivity}.
*/
+@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory {
- /** Delegate interface to launch activities when the actions are selected. */
+ /**
+ * Delegate interface to launch activities when the actions are selected.
+ */
public interface ActionActivityStarter {
/**
* Request an activity launch for the provided target. Implementations may choose to exit
@@ -81,7 +90,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
// Boolean extra used to inform the editor that it may want to customize the editing experience
// for the sharesheet editing flow.
- private static final String EDIT_SOURCE = "edit_source";
+ // Note: EDIT_SOURCE is also used as a signal to avoid sending a 'Component Selected'
+ // ShareResult for this intent when sent via ChooserActivity#safelyStartActivityAsUser
+ static final String EDIT_SOURCE = "edit_source";
private static final String EDIT_SOURCE_SHARESHEET = "sharesheet";
private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label";
@@ -91,20 +102,17 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
private final Context mContext;
- @Nullable
- private final Runnable mCopyButtonRunnable;
- private final Runnable mEditButtonRunnable;
+ @Nullable private Runnable mCopyButtonRunnable;
+ @Nullable private Runnable mEditButtonRunnable;
private final ImmutableList<ChooserAction> mCustomActions;
- private final @Nullable ChooserAction mModifyShareAction;
private final Consumer<Boolean> mExcludeSharedTextAction;
+ @Nullable private final ShareResultSender mShareResultSender;
private final Consumer</* @Nullable */ Integer> mFinishCallback;
- private final EventLog mLogger;
+ private final EventLog mLog;
/**
* @param context
- * @param chooserRequest data about the invocation of the current Sharesheet session.
- * @param integratedDeviceComponents info about other components that are available on this
- * device to implement the supported action types.
+ * @param imageEditor an explicit Activity to launch for editing images
* @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text"
* setting is updated. The argument is whether the shared text is to be excluded.
* @param firstVisibleImageQuery a delegate that provides a reference to the first visible image
@@ -115,54 +123,74 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
*/
public ChooserActionFactory(
Context context,
- ChooserRequestParameters chooserRequest,
- ChooserIntegratedDeviceComponents integratedDeviceComponents,
- EventLog logger,
+ Intent targetIntent,
+ String referrerPackageName,
+ List<ChooserAction> chooserActions,
+ Optional<ComponentName> imageEditor,
+ EventLog log,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
- Consumer</* @Nullable */ Integer> finishCallback) {
+ @Nullable ShareResultSender shareResultSender,
+ Consumer</* @Nullable */ Integer> finishCallback,
+ ClipboardManager clipboardManager) {
this(
context,
makeCopyButtonRunnable(
- context,
- chooserRequest.getTargetIntent(),
- chooserRequest.getReferrerPackageName(),
+ clipboardManager,
+ targetIntent,
+ referrerPackageName,
finishCallback,
- logger),
+ log),
makeEditButtonRunnable(
getEditSharingTarget(
context,
- chooserRequest.getTargetIntent(),
- integratedDeviceComponents),
+ targetIntent,
+ imageEditor),
firstVisibleImageQuery,
activityStarter,
- logger),
- chooserRequest.getChooserActions(),
- chooserRequest.getModifyShareAction(),
+ log),
+ chooserActions,
onUpdateSharedTextIsExcluded,
- logger,
+ log,
+ shareResultSender,
finishCallback);
+
}
@VisibleForTesting
ChooserActionFactory(
Context context,
@Nullable Runnable copyButtonRunnable,
- Runnable editButtonRunnable,
+ @Nullable Runnable editButtonRunnable,
List<ChooserAction> customActions,
- @Nullable ChooserAction modifyShareAction,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
- EventLog logger,
+ EventLog log,
+ @Nullable ShareResultSender shareResultSender,
Consumer</* @Nullable */ Integer> finishCallback) {
mContext = context;
mCopyButtonRunnable = copyButtonRunnable;
mEditButtonRunnable = editButtonRunnable;
mCustomActions = ImmutableList.copyOf(customActions);
- mModifyShareAction = modifyShareAction;
mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
- mLogger = logger;
+ mLog = log;
+ mShareResultSender = shareResultSender;
mFinishCallback = finishCallback;
+
+ if (mShareResultSender != null) {
+ if (mEditButtonRunnable != null) {
+ mEditButtonRunnable = () -> {
+ mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT);
+ editButtonRunnable.run();
+ };
+ }
+ if (mCopyButtonRunnable != null) {
+ mCopyButtonRunnable = () -> {
+ mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY);
+ copyButtonRunnable.run();
+ };
+ }
+ }
}
@Override
@@ -186,11 +214,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
ActionRow.Action actionRow = createCustomAction(
mContext,
mCustomActions.get(i),
- mFinishCallback,
- () -> {
- mLogger.logCustomActionSelected(position);
- }
- );
+ () -> logCustomAction(position),
+ mShareResultSender,
+ mFinishCallback);
if (actionRow != null) {
actions.add(actionRow);
}
@@ -199,21 +225,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
}
/**
- * Provides a share modification action, if any.
- */
- @Override
- @Nullable
- public ActionRow.Action getModifyShareAction() {
- return createCustomAction(
- mContext,
- mModifyShareAction,
- mFinishCallback,
- () -> {
- mLogger.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE);
- });
- }
-
- /**
* <p>
* Creates an exclude-text action that can be called when the user changes shared text
* status in the Media + Text preview.
@@ -229,27 +240,26 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable
private static Runnable makeCopyButtonRunnable(
- Context context,
+ ClipboardManager clipboardManager,
Intent targetIntent,
String referrerPackageName,
Consumer<Integer> finishCallback,
- EventLog logger) {
+ EventLog log) {
final ClipData clipData;
try {
clipData = extractTextToCopy(targetIntent);
} catch (Throwable t) {
Log.e(TAG, "Failed to extract data to copy", t);
- return null;
+ return null;
}
if (clipData == null) {
return null;
}
return () -> {
- ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
- Context.CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName);
- logger.logActionSelected(EventLog.SELECTION_TYPE_COPY);
+ log.logActionSelected(EventLog.SELECTION_TYPE_COPY);
+ Log.d(TAG, "finish due to copy clicked");
finishCallback.accept(Activity.RESULT_OK);
};
}
@@ -278,18 +288,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
return clipData;
}
+ @Nullable
private static TargetInfo getEditSharingTarget(
Context context,
Intent originalIntent,
- ChooserIntegratedDeviceComponents integratedComponents) {
- final ComponentName editorComponent = integratedComponents.getEditSharingComponent();
+ Optional<ComponentName> imageEditor) {
final Intent resolveIntent = new Intent(originalIntent);
// Retain only URI permission grant flags if present. Other flags may prevent the scene
// transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
// FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
- resolveIntent.setComponent(editorComponent);
+ imageEditor.ifPresent(resolveIntent::setComponent);
resolveIntent.setAction(Intent.ACTION_EDIT);
resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET);
String originalAction = originalIntent.getAction();
@@ -308,7 +318,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
final ResolveInfo ri = context.getPackageManager().resolveActivity(
resolveIntent, PackageManager.GET_META_DATA);
if (ri == null || ri.activityInfo == null) {
- Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available");
+ Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available");
return null;
}
@@ -317,28 +327,29 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
ri,
context.getString(R.string.screenshot_edit),
"",
- resolveIntent,
- null);
+ resolveIntent);
dri.getDisplayIconHolder().setDisplayIcon(
context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
return dri;
}
+ @Nullable
private static Runnable makeEditButtonRunnable(
- TargetInfo editSharingTarget,
+ @Nullable TargetInfo editSharingTarget,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
- EventLog logger) {
+ EventLog log) {
+ if (editSharingTarget == null) return null;
return () -> {
// Log share completion via edit.
- logger.logActionSelected(EventLog.SELECTION_TYPE_EDIT);
+ log.logActionSelected(EventLog.SELECTION_TYPE_EDIT);
View firstImageView = null;
try {
firstImageView = firstVisibleImageQuery.call();
} catch (Exception e) { /* ignore */ }
// Action bar is user-independent; always start as primary.
- if (firstImageView == null) {
+ if (firstImageView == null || !isFullyVisible(firstImageView)) {
activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget);
} else {
activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
@@ -348,12 +359,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
}
@Nullable
- private static ActionRow.Action createCustomAction(
+ static ActionRow.Action createCustomAction(
Context context,
- ChooserAction action,
- Consumer<Integer> finishCallback,
- Runnable loggingRunnable) {
- if (action == null || action.getAction() == null) {
+ @Nullable ChooserAction action,
+ Runnable loggingRunnable,
+ ShareResultSender shareResultSender,
+ Consumer</* @Nullable */ Integer> finishCallback) {
+ if (action == null) {
return null;
}
Drawable icon = action.getIcon().loadDrawable(context);
@@ -373,18 +385,26 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
null,
null,
ActivityOptions.makeCustomAnimation(
- context,
- R.anim.slide_in_right,
- R.anim.slide_out_left)
- .toBundle());
+ context,
+ R.anim.slide_in_right,
+ R.anim.slide_out_left)
+ .toBundle());
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled");
}
if (loggingRunnable != null) {
loggingRunnable.run();
}
+ if (shareResultSender != null) {
+ shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED);
+ }
+ Log.d(TAG, "finish due to custom action clicked");
finishCallback.accept(Activity.RESULT_OK);
}
);
}
+
+ void logCustomAction(int position) {
+ mLog.logCustomActionSelected(position);
+ }
}
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 3ce21e29..d4cf82ff 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2008 The Android Open Source Project
+ * Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,25 +16,37 @@
package com.android.intentresolver;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
-import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL;
-import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK;
-
+import static android.app.VoiceInteractor.PickOptionRequest.Option;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
+import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
+
+import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
+
+import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE;
+import static com.android.intentresolver.Flags.fixShortcutsFlashingFixed;
+import static com.android.intentresolver.Flags.interactiveSession;
+import static com.android.intentresolver.Flags.keyboardNavigationFix;
+import static com.android.intentresolver.Flags.rebuildAdaptersOnTargetPinning;
+import static com.android.intentresolver.Flags.refineSystemActions;
+import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra;
+import static com.android.intentresolver.Flags.unselectFinalItem;
+import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs;
+import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL;
+import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK;
import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET;
-import android.annotation.IntDef;
-import android.annotation.Nullable;
-import android.app.Activity;
+import static java.util.Objects.requireNonNull;
+
import android.app.ActivityManager;
import android.app.ActivityOptions;
+import android.app.ActivityThread;
+import android.app.VoiceInteractor;
+import android.app.admin.DevicePolicyEventLogger;
import android.app.prediction.AppPredictor;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
import android.app.prediction.AppTargetId;
+import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
@@ -49,80 +61,131 @@ import android.content.pm.ShortcutInfo;
import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Insets;
+import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
-import android.os.Environment;
+import android.os.StrictMode;
import android.os.SystemClock;
+import android.os.Trace;
import android.os.UserHandle;
-import android.os.UserManager;
-import android.os.storage.StorageManager;
-import android.provider.Settings;
import android.service.chooser.ChooserTarget;
+import android.stats.devicepolicy.DevicePolicyEnums;
+import android.text.TextUtils;
import android.util.Log;
import android.util.Slog;
-import android.util.SparseArray;
+import android.view.Gravity;
+import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewTreeObserver;
+import android.view.Window;
import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TabHost;
+import android.widget.TabWidget;
import android.widget.TextView;
+import android.widget.Toast;
import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.viewmodel.CreationExtras;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
-import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
+import com.android.intentresolver.ChooserRefinementManager.RefinementType;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
-import com.android.intentresolver.contentpreview.BasePreviewViewModel;
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl;
-import com.android.intentresolver.contentpreview.PreviewViewModel;
-import com.android.intentresolver.flags.FeatureFlagRepository;
-import com.android.intentresolver.flags.FeatureFlagRepositoryFactory;
+import com.android.intentresolver.data.model.ChooserRequest;
+import com.android.intentresolver.data.repository.ActivityModelRepository;
+import com.android.intentresolver.data.repository.DevicePolicyResources;
+import com.android.intentresolver.domain.interactor.UserInteractor;
+import com.android.intentresolver.emptystate.CompositeEmptyStateProvider;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
+import com.android.intentresolver.emptystate.EmptyStateProvider;
+import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider;
+import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider;
+import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider;
import com.android.intentresolver.grid.ChooserGridAdapter;
-import com.android.intentresolver.icons.DefaultTargetDataLoader;
+import com.android.intentresolver.icons.Caching;
import com.android.intentresolver.icons.TargetDataLoader;
+import com.android.intentresolver.inject.Background;
import com.android.intentresolver.logging.EventLog;
import com.android.intentresolver.measurements.Tracer;
import com.android.intentresolver.model.AbstractResolverComparator;
import com.android.intentresolver.model.AppPredictionServiceResolverComparator;
import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
+import com.android.intentresolver.platform.AppPredictionAvailable;
+import com.android.intentresolver.platform.ImageEditor;
+import com.android.intentresolver.platform.NearbyShare;
+import com.android.intentresolver.profiles.ChooserMultiProfilePagerAdapter;
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType;
+import com.android.intentresolver.profiles.OnProfileSelectedListener;
+import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener;
+import com.android.intentresolver.profiles.TabConfig;
+import com.android.intentresolver.shared.model.ActivityModel;
+import com.android.intentresolver.shared.model.Profile;
import com.android.intentresolver.shortcuts.AppPredictorFactory;
import com.android.intentresolver.shortcuts.ShortcutLoader;
+import com.android.intentresolver.ui.ActionTitle;
+import com.android.intentresolver.ui.ProfilePagerResources;
+import com.android.intentresolver.ui.ShareResultSender;
+import com.android.intentresolver.ui.ShareResultSenderFactory;
+import com.android.intentresolver.ui.viewmodel.ChooserViewModel;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ChooserNestedScrollView;
import com.android.intentresolver.widget.ImagePreviewView;
+import com.android.intentresolver.widget.ResolverDrawerLayout;
+import com.android.intentresolver.widget.ResolverDrawerLayoutExt;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.content.PackageMonitor;
+import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
+import com.android.internal.util.LatencyTracker;
+
+import com.google.common.collect.ImmutableList;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+import kotlinx.coroutines.CoroutineDispatcher;
-import java.io.File;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.text.Collator;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
-import java.util.Comparator;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import javax.inject.Inject;
/**
* The Chooser Activity handles intent resolution specifically for sharing intents -
* for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}.
*
*/
-public class ChooserActivity extends ResolverActivity implements
- ResolverListAdapter.ResolverListCommunicator {
+@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
+@AndroidEntryPoint(FragmentActivity.class)
+public class ChooserActivity extends Hilt_ChooserActivity implements
+ ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem {
private static final String TAG = "ChooserActivity";
/**
@@ -136,7 +199,6 @@ public class ChooserActivity extends ResolverActivity implements
/**
* Transition name for the first image preview.
* To be used for shared element transition into this activity.
- * @hide
*/
public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image";
@@ -145,6 +207,39 @@ public class ChooserActivity extends ResolverActivity implements
public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share";
private static final String SHORTCUT_TARGET = "shortcut_target";
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ // Inherited properties.
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ private static final String TAB_TAG_PERSONAL = "personal";
+ private static final String TAB_TAG_WORK = "work";
+
+ private static final String LAST_SHOWN_PROFILE = "last_shown_tab_key";
+ public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser";
+
+ private int mLayoutId;
+ private UserHandle mHeaderCreatorUser;
+ private boolean mRegistered;
+ private PackageMonitor mPersonalPackageMonitor;
+ private PackageMonitor mWorkPackageMonitor;
+
+ protected ResolverDrawerLayout mResolverDrawerLayout;
+ private TabHost mTabHost;
+ private ResolverViewPager mViewPager;
+ protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
+ protected final LatencyTracker mLatencyTracker = getLatencyTracker();
+
+ /** See {@link #setRetainInOnStop}. */
+ private boolean mRetainInOnStop;
+ protected Insets mSystemWindowInsets = null;
+ private ResolverActivity.PickTargetOptionRequest mPickOptionRequest;
+
+ @Nullable
+ private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////
+
+
// TODO: these data structures are for one-time use in shuttling data from where they're
// populated in `ShortcutToChooserTargetConverter` to where they're consumed in
// `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`.
@@ -153,44 +248,44 @@ public class ChooserActivity extends ResolverActivity implements
private final Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache = new HashMap<>();
private final Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache = new HashMap<>();
- public static final int TARGET_TYPE_DEFAULT = 0;
- public static final int TARGET_TYPE_CHOOSER_TARGET = 1;
- public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2;
- public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3;
+ static final int TARGET_TYPE_DEFAULT = 0;
+ static final int TARGET_TYPE_CHOOSER_TARGET = 1;
+ static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2;
+ static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3;
private static final int SCROLL_STATUS_IDLE = 0;
private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1;
private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2;
- @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = {
- TARGET_TYPE_DEFAULT,
- TARGET_TYPE_CHOOSER_TARGET,
- TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER,
- TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE
- })
- @Retention(RetentionPolicy.SOURCE)
- public @interface ShareTargetType {}
-
- private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents;
-
- /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the
- * only assignment there, and expect it to be ready by the time we ever use it --
- * someday if we move all the usage to a component with a narrower lifecycle (something that
- * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we
- * should be able to make this assignment as "final."
- */
- @Nullable
- private ChooserRequestParameters mChooserRequest;
+ @Inject public UserInteractor mUserInteractor;
+ @Inject @Background public CoroutineDispatcher mBackgroundDispatcher;
+ @Inject public ChooserHelper mChooserHelper;
+ @Inject public EventLog mEventLog;
+ @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable;
+ @Inject @ImageEditor public Optional<ComponentName> mImageEditor;
+ @Inject @NearbyShare public Optional<ComponentName> mNearbyShare;
+ @Inject
+ @Caching
+ public TargetDataLoader mTargetDataLoader;
+ @Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public ProfilePagerResources mProfilePagerResources;
+ @Inject public PackageManager mPackageManager;
+ @Inject public ClipboardManager mClipboardManager;
+ @Inject public IntentForwarding mIntentForwarding;
+ @Inject public ShareResultSenderFactory mShareResultSenderFactory;
+ @Inject public ActivityModelRepository mActivityModelRepository;
+
+ private ActivityModel mActivityModel;
+ private ChooserRequest mRequest;
+ private ProfileHelper mProfiles;
+ private ProfileAvailability mProfileAvailability;
+ @Nullable private ShareResultSender mShareResultSender;
private ChooserRefinementManager mRefinementManager;
- private FeatureFlagRepository mFeatureFlagRepository;
private ChooserContentPreviewUi mChooserContentPreviewUi;
private boolean mShouldDisplayLandscape;
- // statsd logger wrapper
- protected EventLog mEventLog;
-
private long mChooserShownTime;
protected boolean mIsSuccessfullySelected;
@@ -212,14 +307,10 @@ public class ChooserActivity extends ResolverActivity implements
private int mScrollStatus = SCROLL_STATUS_IDLE;
- @VisibleForTesting
- protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate =
new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout);
- private View mContentView = null;
-
- private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>();
+ private final Map<Integer, ProfileRecord> mProfileRecords = new LinkedHashMap<>();
private boolean mExcludeSharedText = false;
/**
@@ -230,101 +321,347 @@ public class ChooserActivity extends ResolverActivity implements
*/
private boolean mFinishWhenStopped = false;
- public ChooserActivity() {}
+ private final AtomicLong mIntentReceivedTime = new AtomicLong(-1);
+
+ protected ActivityModel createActivityModel() {
+ return ActivityModel.createFrom(this);
+ }
+
+ private ChooserViewModel mViewModel;
+
+ @NonNull
+ @Override
+ public CreationExtras getDefaultViewModelCreationExtras() {
+ // DEFAULT_ARGS_KEY extra is saved for each ViewModel we create. ComponentActivity puts the
+ // initial intent's extra into DEFAULT_ARGS_KEY thus we store these values 2 times (3 if we
+ // count the initial intent). We don't need those values to be saved as they don't capture
+ // the state.
+ return replaceDefaultArgs(super.getDefaultViewModelCreationExtras());
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
- if (Settings.Global.getInt(getContentResolver(), Settings.Global.SECURE_FRP_MODE, 0) == 1) {
- Log.e(TAG, "Sharing disabled due to active FRP lock.");
- super.onCreate(savedInstanceState);
- finish();
- return;
+ super.onCreate(savedInstanceState);
+ Log.i(TAG, "onCreate");
+ mActivityModelRepository.initialize(this::createActivityModel);
+
+ setTheme(R.style.Theme_DeviceDefault_Chooser);
+
+ // Initializer is invoked when this function returns, via Lifecycle.
+ mChooserHelper.setInitializer(this::initialize);
+ mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged);
+ mChooserHelper.setOnPendingSelection(this::onPendingSelection);
+ if (unselectFinalItem()) {
+ mChooserHelper.setOnHasSelections(this::onHasSelections);
}
- Tracer.INSTANCE.markLaunched();
- final long intentReceivedTime = System.currentTimeMillis();
- mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET);
+ }
+ private int mInitialProfile = -1;
- getEventLog().logSharesheetTriggered();
+ @Override
+ protected final void onStart() {
+ super.onStart();
+ this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
+ }
+
+ @Override
+ protected final void onResume() {
+ super.onResume();
+ Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
+ mFinishWhenStopped = false;
+ mRefinementManager.onActivityResume();
+ }
- mFeatureFlagRepository = createFeatureFlagRepository();
- mIntegratedDeviceComponents = getIntegratedDeviceComponents();
+ @Override
+ protected final void onStop() {
+ super.onStop();
- try {
- mChooserRequest = new ChooserRequestParameters(
- getIntent(),
- getReferrerPackageName(),
- getReferrer(),
- mFeatureFlagRepository);
- } catch (IllegalArgumentException e) {
- Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
+ final Window window = this.getWindow();
+ final WindowManager.LayoutParams attrs = window.getAttributes();
+ attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
+ window.setAttributes(attrs);
+
+ if (mRegistered) {
+ mPersonalPackageMonitor.unregister();
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mRegistered = false;
+ }
+ final Intent intent = getIntent();
+ if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
+ && !mRetainInOnStop) {
+ // This resolver is in the unusual situation where it has been
+ // launched at the top of a new task. We don't let it be added
+ // to the recent tasks shown to the user, and we need to make sure
+ // that each time we are launched we get the correct launching
+ // uid (not re-using the same resolver from an old launching uid),
+ // so we will now finish ourself since being no longer visible,
+ // the user probably can't get back to us.
+ if (!isChangingConfigurations()) {
+ Log.d(TAG, "finishing in onStop");
+ finish();
+ }
+ }
+
+ if (mRefinementManager != null) {
+ mRefinementManager.onActivityStop(isChangingConfigurations());
+ }
+
+ if (mFinishWhenStopped) {
+ mFinishWhenStopped = false;
finish();
- super_onCreate(null);
- return;
}
+ }
- mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
+ @Override
+ protected final void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mViewPager != null) {
+ outState.putInt(
+ LAST_SHOWN_PROFILE, mChooserMultiProfilePagerAdapter.getActiveProfile());
+ }
+ }
- mRefinementManager.getRefinementCompletion().observe(this, completion -> {
- if (completion.consume()) {
- TargetInfo targetInfo = completion.getTargetInfo();
- // targetInfo is non-null if the refinement process was successful.
- if (targetInfo != null) {
- maybeRemoveSharedText(targetInfo);
-
- // We already block suspended targets from going to refinement, and we probably
- // can't recover a Chooser session if that's the reason the refined target fails
- // to launch now. Fire-and-forget the refined launch; ignore the return value
- // and just make sure the Sharesheet session gets cleaned up regardless.
- ChooserActivity.super.onTargetSelected(targetInfo, false);
+ @Override
+ protected final void onRestart() {
+ super.onRestart();
+ if (mChooserMultiProfilePagerAdapter.hasPageForProfile(Profile.Type.PRIVATE.ordinal())
+ && !mProfileAvailability.isAvailable(mProfiles.getPrivateProfile())) {
+ Log.d(TAG, "Exiting due to unavailable profile");
+ finish();
+ return;
+ }
+
+ if (!mRegistered) {
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getPersonalHandle(),
+ false);
+ if (mProfiles.getWorkProfilePresent()) {
+ if (mWorkPackageMonitor == null) {
+ mWorkPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getWorkListAdapter());
}
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getWorkHandle(),
+ false);
+ }
+ mRegistered = true;
+ }
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+ }
- finish();
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (!isChangingConfigurations() && mPickOptionRequest != null) {
+ mPickOptionRequest.cancel();
+ }
+ if (mChooserMultiProfilePagerAdapter != null) {
+ mChooserMultiProfilePagerAdapter.destroy();
+ }
+
+ if (isFinishing()) {
+ mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
+ if (interactiveSession() && mViewModel != null) {
+ mViewModel.getInteractiveSessionInteractor().endSession();
}
- });
+ }
- BasePreviewViewModel previewViewModel =
- new ViewModelProvider(this, createPreviewViewModelFactory())
- .get(BasePreviewViewModel.class);
- mChooserContentPreviewUi = new ChooserContentPreviewUi(
- getLifecycle(),
- previewViewModel.createOrReuseProvider(mChooserRequest),
- mChooserRequest.getTargetIntent(),
- previewViewModel.createOrReuseImageLoader(),
- createChooserActionFactory(),
- mEnterTransitionAnimationDelegate,
- new HeadlineGeneratorImpl(this));
+ mBackgroundThreadPoolExecutor.shutdownNow();
+
+ destroyProfileRecords();
+ }
+
+ /** DO NOT CALL. Only for use from ChooserHelper as a callback. */
+ private void initialize() {
+
+ mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class);
+ mRequest = mViewModel.getRequest().getValue();
+ mActivityModel = mViewModel.getActivityModel();
+
+ mProfiles = new ProfileHelper(
+ mUserInteractor,
+ mBackgroundDispatcher);
+
+ mProfileAvailability = new ProfileAvailability(
+ mUserInteractor,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher);
+
+ mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated);
+
+ mIntentReceivedTime.set(System.currentTimeMillis());
+ mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET);
mPinnedSharedPrefs = getPinnedSharedPrefs(this);
+ updateShareResultSender();
- mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row);
+ mMaxTargetsPerRow =
+ getResources().getInteger(R.integer.config_chooser_max_targets_per_row);
mShouldDisplayLandscape =
shouldDisplayLandscape(getResources().getConfiguration().orientation);
- setRetainInOnStop(mChooserRequest.shouldRetainInOnStop());
+ setRetainInOnStop(mRequest.shouldRetainInOnStop());
createProfileRecords(
new AppPredictorFactory(
- getApplicationContext(),
- mChooserRequest.getSharedText(),
- mChooserRequest.getTargetIntentFilter()),
- mChooserRequest.getTargetIntentFilter());
-
- super.onCreate(
- savedInstanceState,
- mChooserRequest.getTargetIntent(),
- mChooserRequest.getAdditionalTargets(),
- mChooserRequest.getTitle(),
- mChooserRequest.getDefaultTitleResource(),
- mChooserRequest.getInitialIntents(),
- /* resolutionList= */ null,
- /* supportsAlwaysUseOption= */ false,
- new DefaultTargetDataLoader(this, getLifecycle(), false),
- /* safeForwardingMode= */ true);
+ this,
+ Objects.toString(mRequest.getSharedText(), null),
+ mRequest.getShareTargetFilter(),
+ mAppPredictionAvailable
+ ),
+ mRequest.getShareTargetFilter()
+ );
+
+
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ mProfileRecords.values(),
+ mProfileAvailability,
+ mRequest.getInitialIntents(),
+ mMaxTargetsPerRow);
+
+ maybeDisableRecentsScreenshot(mProfiles, mProfileAvailability);
+
+ if (!configureContentView(mTargetDataLoader)) {
+ mPersonalPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getPersonalListAdapter());
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getPersonalHandle(),
+ false
+ );
+ if (mProfiles.getWorkProfilePresent()) {
+ mWorkPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getWorkListAdapter());
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getWorkHandle(),
+ false
+ );
+ }
+ mRegistered = true;
+ final ResolverDrawerLayout rdl = findViewById(
+ com.android.internal.R.id.contentPanel);
+ if (rdl != null) {
+ rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() {
+ @Override
+ public void onDismissed() {
+ finish();
+ }
+ });
+
+ boolean hasTouchScreen = mPackageManager
+ .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN);
+
+ if (isVoiceInteraction() || !hasTouchScreen) {
+ rdl.setCollapsed(false);
+ }
+
+ rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
+ rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets);
+
+ mResolverDrawerLayout = rdl;
+ }
+
+ Intent intent = mRequest.getTargetIntent();
+ final Set<String> categories = intent.getCategories();
+ MetricsLogger.action(this,
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
+ ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED
+ : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED,
+ intent.getAction() + ":" + intent.getType() + ":"
+ + (categories != null ? Arrays.toString(categories.toArray())
+ : ""));
+ }
+
+ getEventLog().logSharesheetTriggered();
+ mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
+ mRefinementManager.getRefinementCompletion().observe(this, completion -> {
+ if (completion.consume()) {
+ if (completion.getRefinedIntent() == null) {
+ finish();
+ return;
+ }
+
+ // Prepare to regenerate our "system actions" based on the refined intent.
+ // TODO: optimize if needed. `TARGET_INFO` cases don't require a new action
+ // factory at all. And if we break up `ChooserActionFactory`, we could avoid
+ // resolving a new editor intent unless we're handling an `EDIT_ACTION`.
+ ChooserActionFactory refinedActionFactory =
+ createChooserActionFactory(completion.getRefinedIntent());
+ switch (completion.getType()) {
+ case TARGET_INFO: {
+ TargetInfo refinedTarget = completion
+ .getOriginalTargetInfo()
+ .tryToCloneWithAppliedRefinement(
+ completion.getRefinedIntent());
+ if (refinedTarget == null) {
+ Log.e(TAG, "Failed to apply refinement to any matching source intent");
+ } else {
+ maybeRemoveSharedText(refinedTarget);
+
+ // We already block suspended targets from going to refinement, and we
+ // probably can't recover a Chooser session if that's the reason the
+ // refined target fails to launch now. Fire-and-forget the refined
+ // launch, and make sure Sharesheet gets cleaned up regardless of the
+ // outcome of that launch.launch; ignore
+
+ safelyStartActivity(refinedTarget);
+ }
+ }
+ break;
+ case COPY_ACTION: {
+ if (refinedActionFactory.getCopyButtonRunnable() != null) {
+ refinedActionFactory.getCopyButtonRunnable().run();
+ }
+ }
+ break;
+
+ case EDIT_ACTION: {
+ if (refinedActionFactory.getEditButtonRunnable() != null) {
+ refinedActionFactory.getEditButtonRunnable().run();
+ }
+ }
+ break;
+ }
+
+ finish();
+ }
+ });
+ ChooserContentPreviewUi.ActionFactory actionFactory =
+ decorateActionFactoryWithRefinement(
+ createChooserActionFactory(mRequest.getTargetIntent()));
+ mChooserContentPreviewUi = new ChooserContentPreviewUi(
+ getCoroutineScope(getLifecycle()),
+ mViewModel.getPreviewDataProvider(),
+ mRequest,
+ mViewModel.getImageLoader(),
+ actionFactory,
+ createModifyShareActionFactory(),
+ mEnterTransitionAnimationDelegate,
+ new HeadlineGeneratorImpl(this),
+ mRequest.getContentTypeHint(),
+ mRequest.getMetadataText());
+ updateStickyContentPreview();
+ if (shouldShowStickyContentPreview()) {
+ getEventLog().logActionShareWithPreview(
+ mChooserContentPreviewUi.getPreferredContentPreview());
+ }
mChooserShownTime = System.currentTimeMillis();
- final long systemCost = mChooserShownTime - intentReceivedTime;
+ final long systemCost = mChooserShownTime - mIntentReceivedTime.get();
getEventLog().logChooserActivityShown(
- isWorkProfile(), mChooserRequest.getTargetType(), systemCost);
-
+ isWorkProfile(), mRequest.getTargetType(), systemCost);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange);
@@ -334,74 +671,812 @@ public class ChooserActivity extends ResolverActivity implements
getEventLog().logSharesheetExpansionChanged(isCollapsed);
});
}
-
if (DEBUG) {
Log.d(TAG, "System Time Cost is " + systemCost);
}
-
getEventLog().logShareStarted(
- getReferrerPackageName(),
- mChooserRequest.getTargetType(),
- mChooserRequest.getCallerChooserTargets().size(),
- (mChooserRequest.getInitialIntents() == null)
- ? 0 : mChooserRequest.getInitialIntents().length,
+ mRequest.getReferrerPackage(),
+ mRequest.getTargetType(),
+ mRequest.getCallerChooserTargets().size(),
+ mRequest.getInitialIntents().size(),
isWorkProfile(),
mChooserContentPreviewUi.getPreferredContentPreview(),
- mChooserRequest.getTargetAction(),
- mChooserRequest.getChooserActions().size(),
- mChooserRequest.getModifyShareAction() != null
+ mRequest.getTargetAction(),
+ mRequest.getChooserActions().size(),
+ mRequest.getModifyShareAction() != null
);
-
mEnterTransitionAnimationDelegate.postponeTransition();
+ mInitialProfile = findSelectedProfile();
+ Tracer.INSTANCE.markLaunched();
+
+ if (isInteractiveSession()) {
+ configureInteractiveSessionWindow();
+ updateInteractiveArea();
+ }
}
- @VisibleForTesting
- protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
- return ChooserIntegratedDeviceComponents.get(this, new SecureSettings());
+ private void maybeDisableRecentsScreenshot(
+ ProfileHelper profileHelper, ProfileAvailability profileAvailability) {
+ for (Profile profile : profileHelper.getProfiles()) {
+ if (profile.getType() == Profile.Type.PRIVATE) {
+ if (profileAvailability.isAvailable(profile)) {
+ // Show blank screen in Recent preview if private profile is available
+ // to not leak its presence.
+ setRecentsScreenshotEnabled(false);
+ }
+ return;
+ }
+ }
+ }
+
+ private void onChooserRequestChanged(ChooserRequest chooserRequest) {
+ if (mRequest == chooserRequest) {
+ return;
+ }
+ boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest);
+ mRequest = chooserRequest;
+ updateShareResultSender();
+ mChooserContentPreviewUi.updateModifyShareAction();
+ if (recreateAdapters) {
+ recreatePagerAdapter();
+ } else {
+ setTabsViewEnabled(true);
+ }
+ }
+
+ private void onPendingSelection() {
+ setTabsViewEnabled(false);
+ }
+
+ private void onHasSelections(boolean hasSelections) {
+ mChooserMultiProfilePagerAdapter.setTargetsEnabled(hasSelections);
+ }
+
+ private void configureInteractiveSessionWindow() {
+ if (!isInteractiveSession()) {
+ Log.wtf(TAG, "Unexpected user of the method; should be an interactive session");
+ return;
+ }
+ final Window window = getWindow();
+ if (window == null) {
+ return;
+ }
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+ window.addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY);
+ }
+
+ private void updateInteractiveArea() {
+ if (!isInteractiveSession()) {
+ Log.wtf(TAG, "Unexpected user of the method; should be an interactive session");
+ return;
+ }
+ final View contentView = findViewById(android.R.id.content);
+ final ResolverDrawerLayout rdl = mResolverDrawerLayout;
+ if (contentView == null || rdl == null) {
+ return;
+ }
+ final Rect rect = new Rect();
+ contentView.getViewTreeObserver().addOnComputeInternalInsetsListener((info) -> {
+ int oldTop = rect.top;
+ rdl.getBoundsInWindow(rect, true);
+ int left = rect.left;
+ int top = rect.top;
+ ResolverDrawerLayoutExt.getVisibleDrawerRect(rdl, rect);
+ rect.offset(left, top);
+ if (oldTop != rect.top) {
+ mViewModel.getInteractiveSessionInteractor().sendTopDrawerTopOffsetChange(rect.top);
+ }
+ info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
+ info.touchableRegion.set(new Rect(rect));
+ });
+ }
+
+ private void onAppTargetsLoaded(ResolverListAdapter listAdapter) {
+ Log.d(TAG, "onAppTargetsLoaded("
+ + "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")");
+
+ if (mChooserMultiProfilePagerAdapter == null) {
+ return;
+ }
+ if (!isProfilePagerAdapterAttached()
+ && listAdapter == mChooserMultiProfilePagerAdapter.getActiveListAdapter()) {
+ mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager);
+ setTabsViewEnabled(true);
+ }
+ }
+
+ private void updateShareResultSender() {
+ IntentSender chosenComponentSender = mRequest.getChosenComponentSender();
+ if (chosenComponentSender != null) {
+ mShareResultSender = mShareResultSenderFactory.create(
+ mViewModel.getActivityModel().getLaunchedFromUid(), chosenComponentSender);
+ } else {
+ mShareResultSender = null;
+ }
+ }
+
+ private boolean shouldUpdateAdapters(
+ ChooserRequest oldChooserRequest, ChooserRequest newChooserRequest) {
+ Intent oldTargetIntent = oldChooserRequest.getTargetIntent();
+ Intent newTargetIntent = newChooserRequest.getTargetIntent();
+ List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets();
+ List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets();
+ List<ComponentName> oldExcluded = oldChooserRequest.getFilteredComponentNames();
+ List<ComponentName> newExcluded = newChooserRequest.getFilteredComponentNames();
+
+ // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates -
+ // an artifact of the current implementation; revisit.
+ return !oldTargetIntent.equals(newTargetIntent)
+ || !oldAltIntents.equals(newAltIntents)
+ || (shareouselUpdateExcludeComponentsExtra()
+ && !oldExcluded.equals(newExcluded));
+ }
+
+ private void recreatePagerAdapter() {
+ destroyProfileRecords();
+ createProfileRecords(
+ new AppPredictorFactory(
+ this,
+ Objects.toString(mRequest.getSharedText(), null),
+ mRequest.getShareTargetFilter(),
+ mAppPredictionAvailable
+ ),
+ mRequest.getShareTargetFilter()
+ );
+
+ int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage();
+ if (mChooserMultiProfilePagerAdapter != null) {
+ mChooserMultiProfilePagerAdapter.destroy();
+ }
+ // Update the pager adapter but do not attach it to the view till the targets are reloaded,
+ // see onChooserAppTargetsLoaded method.
+ ChooserMultiProfilePagerAdapter oldPagerAdapter =
+ mChooserMultiProfilePagerAdapter;
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ /* context = */ this,
+ mProfilePagerResources,
+ mRequest,
+ mProfiles,
+ mProfileRecords.values(),
+ mProfileAvailability,
+ mRequest.getInitialIntents(),
+ mMaxTargetsPerRow);
+ mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage);
+ for (int i = 0, count = mChooserMultiProfilePagerAdapter.getItemCount(); i < count; i++) {
+ mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i)
+ .getListAdapter().setAnimateItems(false);
+ }
+ if (mPersonalPackageMonitor != null) {
+ mPersonalPackageMonitor.unregister();
+ }
+ mPersonalPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getPersonalListAdapter());
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getPersonalHandle(),
+ false);
+ if (mProfiles.getWorkProfilePresent()) {
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mWorkPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getWorkListAdapter());
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getWorkHandle(),
+ false);
+ }
+ postRebuildList(
+ mChooserMultiProfilePagerAdapter.rebuildTabs(
+ mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent()));
+ if (fixShortcutsFlashingFixed() && oldPagerAdapter != null) {
+ for (int i = 0, count = mChooserMultiProfilePagerAdapter.getCount(); i < count; i++) {
+ ChooserListAdapter listAdapter =
+ mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i)
+ .getListAdapter();
+ ChooserListAdapter oldListAdapter =
+ oldPagerAdapter.getListAdapterForUserHandle(listAdapter.getUserHandle());
+ if (oldListAdapter != null) {
+ listAdapter.copyDirectTargetsFrom(oldListAdapter);
+ listAdapter.setDirectTargetsEnabled(false);
+ }
+ }
+ }
+ setTabsViewEnabled(false);
+ if (mSystemWindowInsets != null) {
+ applyFooterView(mSystemWindowInsets.bottom);
+ }
+ }
+
+ private void setTabsViewEnabled(boolean isEnabled) {
+ TabWidget tabs = mTabHost.getTabWidget();
+ if (tabs != null) {
+ tabs.setEnabled(isEnabled);
+ }
+ View tabContent = mTabHost.findViewById(com.android.internal.R.id.profile_pager);
+ if (tabContent != null) {
+ tabContent.setEnabled(isEnabled);
+ }
}
@Override
- protected int appliedThemeResId() {
- return R.style.Theme_DeviceDefault_Chooser;
+ protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
+ if (mViewPager != null) {
+ int profile = savedInstanceState.getInt(LAST_SHOWN_PROFILE);
+ int profileNumber = mChooserMultiProfilePagerAdapter.getPageNumberForProfile(profile);
+ if (profileNumber != -1) {
+ mViewPager.setCurrentItem(profileNumber);
+ mInitialProfile = profile;
+ }
+ }
+ mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ // Inherited methods
+ //////////////////////////////////////////////////////////////////////////////////////////////
+
+ private boolean isAutolaunching() {
+ return !mRegistered && isFinishing();
+ }
+
+ private boolean maybeAutolaunchIfSingleTarget() {
+ int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
+ if (count != 1) {
+ return false;
+ }
+
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {
+ return false;
+ }
+
+ // Only one target, so we're a candidate to auto-launch!
+ final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
+ .targetInfoForPosition(0, false);
+ if (shouldAutoLaunchSingleChoice(target)) {
+ Log.d(TAG, "auto launching " + target + " and finishing.");
+ safelyStartActivity(target);
+ finish();
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isTwoPagePersonalAndWorkConfiguration() {
+ return (mChooserMultiProfilePagerAdapter.getCount() == 2)
+ && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL)
+ && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK);
+ }
+
+ /**
+ * When we have a personal and a work profile, we auto launch in the following scenario:
+ * - There is 1 resolved target on each profile
+ * - That target is the same app on both profiles
+ * - The target app has permission to communicate cross profiles
+ * - The target app has declared it supports cross-profile communication via manifest metadata
+ */
+ private boolean maybeAutolaunchIfCrossProfileSupported() {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
+ return false;
+ }
+
+ ResolverListAdapter activeListAdapter =
+ (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mChooserMultiProfilePagerAdapter.getWorkListAdapter();
+
+ ResolverListAdapter inactiveListAdapter =
+ (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mChooserMultiProfilePagerAdapter.getWorkListAdapter()
+ : mChooserMultiProfilePagerAdapter.getPersonalListAdapter();
+
+ if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) {
+ return false;
+ }
+
+ if ((activeListAdapter.getUnfilteredCount() != 1)
+ || (inactiveListAdapter.getUnfilteredCount() != 1)) {
+ return false;
+ }
+
+ TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false);
+ TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false);
+ if (!Objects.equals(
+ activeProfileTarget.getResolvedComponentName(),
+ inactiveProfileTarget.getResolvedComponentName())) {
+ return false;
+ }
+
+ if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) {
+ return false;
+ }
+
+ String packageName = activeProfileTarget.getResolvedComponentName().getPackageName();
+ if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) {
+ return false;
+ }
+
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET)
+ .setBoolean(activeListAdapter.getUserHandle()
+ .equals(mProfiles.getPersonalHandle()))
+ .setStrings(getMetricsCategory())
+ .write();
+ safelyStartActivity(activeProfileTarget);
+ Log.d(TAG, "auto launching! " + activeProfileTarget);
+ finish();
+ return true;
+ }
+
+ /**
+ * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
+ */
+ private boolean maybeAutolaunchActivity() {
+ if (isInteractiveSession()) {
+ return false;
+ }
+ int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount();
+ // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the
+ // correct intent-picker UIs (e.g., mini-resolver) if it was launched without
+ // ACTION_SEND.
+ if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) {
+ return true;
+ } else if (maybeAutolaunchIfCrossProfileSupported()) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override // ResolverListCommunicator
+ public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
+ boolean rebuildCompleted) {
+ if (isAutolaunching()) {
+ return;
+ }
+ if (mChooserMultiProfilePagerAdapter
+ .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) {
+ mChooserMultiProfilePagerAdapter
+ .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter);
+ } else {
+ mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter);
+ }
+ // showEmptyResolverListEmptyState can mark the tab as loaded,
+ // which is a precondition for auto launching
+ if (rebuildCompleted && maybeAutolaunchActivity()) {
+ return;
+ }
+ if (doPostProcessing) {
+ maybeCreateHeader(listAdapter);
+ onListRebuilt(listAdapter, rebuildCompleted);
+ }
+ }
+
+ private CharSequence getOrLoadDisplayLabel(TargetInfo info) {
+ if (info.isDisplayResolveInfo()) {
+ mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info);
+ }
+ CharSequence displayLabel = info.getDisplayLabel();
+ return displayLabel == null ? "" : displayLabel;
+ }
+
+ protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
+ final ActionTitle title = ActionTitle.forAction(intent.getAction());
+
+ // While there may already be a filtered item, we can only use it in the title if the list
+ // is already sorted and all information relevant to it is already in the list.
+ final boolean named =
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;
+ if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) {
+ return getString(defaultTitleRes);
+ } else {
+ return named
+ ? getString(
+ title.namedTitleRes,
+ getOrLoadDisplayLabel(
+ mChooserMultiProfilePagerAdapter
+ .getActiveListAdapter().getFilteredItem()))
+ : getString(title.titleRes);
+ }
+ }
+
+ /**
+ * Configure the area above the app selection list (title, content preview, etc).
+ */
+ private void maybeCreateHeader(ResolverListAdapter listAdapter) {
+ if (mHeaderCreatorUser != null
+ && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) {
+ return;
+ }
+ if (!mProfiles.getWorkProfilePresent()
+ && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) {
+ final TextView titleView = findViewById(com.android.internal.R.id.title);
+ if (titleView != null) {
+ titleView.setVisibility(View.GONE);
+ }
+ }
+
+ CharSequence title = mRequest.getTitle() != null
+ ? mRequest.getTitle()
+ : getTitleForAction(mRequest.getTargetIntent(),
+ mRequest.getDefaultTitleResource());
+
+ if (!TextUtils.isEmpty(title)) {
+ final TextView titleView = findViewById(com.android.internal.R.id.title);
+ if (titleView != null) {
+ titleView.setText(title);
+ }
+ setTitle(title);
+ }
+
+ final ImageView iconView = findViewById(com.android.internal.R.id.icon);
+ if (iconView != null) {
+ listAdapter.loadFilteredItemIconTaskAsync(iconView);
+ }
+ mHeaderCreatorUser = listAdapter.getUserHandle();
+ }
+
+ /** Start the activity specified by the {@link TargetInfo}.*/
+ public final void safelyStartActivity(TargetInfo cti) {
+ // In case cloned apps are present, we would want to start those apps in cloned user
+ // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle
+ // identifies the correct user space in such cases.
+ UserHandle activityUserHandle = cti.getResolveInfo().userHandle;
+ safelyStartActivityAsUser(cti, activityUserHandle, null);
+ }
+
+ protected final void safelyStartActivityAsUser(
+ TargetInfo cti, UserHandle user, @Nullable Bundle options) {
+ // We're dispatching intents that might be coming from legacy apps, so
+ // don't kill ourselves.
+ StrictMode.disableDeathOnFileUriExposure();
+ try {
+ safelyStartActivityInternal(cti, user, options);
+ } finally {
+ StrictMode.enableDeathOnFileUriExposure();
+ }
+ }
+
+ @VisibleForTesting
+ protected void safelyStartActivityInternal(
+ TargetInfo cti, UserHandle user, @Nullable Bundle options) {
+ // If the target is suspended, the activity will not be successfully launched.
+ // Do not unregister from package manager updates in this case
+ if (!cti.isSuspended() && mRegistered) {
+ if (mPersonalPackageMonitor != null) {
+ mPersonalPackageMonitor.unregister();
+ }
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mRegistered = false;
+ }
+ // If needed, show that intent is forwarded
+ // from managed profile to owner or other way around.
+ String profileSwitchMessage = mIntentForwarding.forwardMessageFor(
+ mRequest.getTargetIntent());
+ if (profileSwitchMessage != null) {
+ Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show();
+ }
+ try {
+ if (cti.startAsCaller(this, options, user.getIdentifier())) {
+ // Prevent sending a second chooser result when starting the edit action intent.
+ if (!cti.getTargetIntent().hasExtra(EDIT_SOURCE)) {
+ maybeSendShareResult(cti, user);
+ }
+ maybeLogCrossProfileTargetLaunch(cti, user);
+ }
+ } catch (RuntimeException e) {
+ Slog.wtf(TAG,
+ "Unable to launch as uid " + mActivityModel.getLaunchedFromUid()
+ + " package " + mActivityModel.getLaunchedFromPackage()
+ + ", while running in " + ActivityThread.currentProcessName(), e);
+ }
+ }
+
+ private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
+ if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) {
+ return;
+ }
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
+ .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle()))
+ .setStrings(getMetricsCategory(),
+ cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
+ .write();
+ }
+
+ private LatencyTracker getLatencyTracker() {
+ return LatencyTracker.getInstance(this);
+ }
+
+ /**
+ * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets
+ * called and we are launched in a new task.
+ */
+ protected final void setRetainInOnStop(boolean retainInOnStop) {
+ mRetainInOnStop = retainInOnStop;
}
- protected FeatureFlagRepository createFeatureFlagRepository() {
- return new FeatureFlagRepositoryFactory().create(getApplicationContext());
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+ return new CrossProfileIntentsChecker(getContentResolver());
+ }
+
+ protected final EmptyStateProvider createEmptyStateProvider(
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability) {
+ EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
+
+ EmptyStateProvider workProfileOffEmptyStateProvider =
+ new WorkProfilePausedEmptyStateProvider(
+ this,
+ profileHelper,
+ profileAvailability,
+ /* onSwitchOnWorkSelectedListener = */
+ () -> {
+ if (mOnSwitchOnWorkSelectedListener != null) {
+ mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+ }
+ },
+ getMetricsCategory());
+
+ EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
+ mProfiles,
+ mProfileAvailability,
+ getMetricsCategory(),
+ mProfilePagerResources
+ );
+
+ // Return composite provider, the order matters (the higher, the more priority)
+ return new CompositeEmptyStateProvider(
+ blockerEmptyStateProvider,
+ workProfileOffEmptyStateProvider,
+ noAppsEmptyStateProvider
+ );
+ }
+
+ /**
+ * Returns the {@link List} of {@link UserHandle} to pass on to the
+ * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}.
+ */
+ private List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) {
+ return getResolverRankerServiceUserHandleListInternal(userHandle);
+ }
+
+ private List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) {
+ List<UserHandle> userList = new ArrayList<>();
+ userList.add(userHandle);
+ // Add clonedProfileUserHandle to the list only if we are:
+ // a. Building the Personal Tab.
+ // b. CloneProfile exists on the device.
+ if (userHandle.equals(mProfiles.getPersonalHandle())
+ && mProfiles.getCloneUserPresent()) {
+ userList.add(mProfiles.getCloneHandle());
+ }
+ return userList;
+ }
+
+ /**
+ * Start activity as a fixed user handle.
+ * @param cti TargetInfo to be launched.
+ * @param user User to launch this activity as.
+ */
+ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED)
+ public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
+ safelyStartActivityAsUser(cti, user, null);
+ }
+
+ @Override // ResolverListCommunicator
+ public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
+ mChooserMultiProfilePagerAdapter.onHandlePackagesChanged(
+ (ChooserListAdapter) listAdapter,
+ mProfileAvailability.getWaitingToEnableProfile());
+ }
+
+ final Option optionForChooserTarget(TargetInfo target, int index) {
+ return new Option(getOrLoadDisplayLabel(target), index);
+ }
+
+ @Override // ResolverListCommunicator
+ public final void sendVoiceChoicesIfNeeded() {
+ if (!isVoiceInteraction()) {
+ // Clearly not needed.
+ return;
+ }
+
+ int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount();
+ final Option[] options = new Option[count];
+ for (int i = 0; i < options.length; i++) {
+ TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);
+ if (target == null) {
+ // If this occurs, a new set of targets is being loaded. Let that complete,
+ // and have the next call to send voice choices proceed instead.
+ return;
+ }
+ options[i] = optionForChooserTarget(target, i);
+ }
+
+ mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest(
+ new VoiceInteractor.Prompt(getTitle()), options, null);
+ getVoiceInteractor().submitRequest(mPickOptionRequest);
+ }
+
+ /**
+ * Sets up the content view.
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ private boolean configureContentView(TargetDataLoader targetDataLoader) {
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) {
+ throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() "
+ + "cannot be null.");
+ }
+ Trace.beginSection("configureContentView");
+ // We partially rebuild the inactive adapter to determine if we should auto launch
+ // isTabLoaded will be true here if the empty state screen is shown instead of the list.
+ boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(
+ mProfiles.getWorkProfilePresent());
+
+ mLayoutId = R.layout.chooser_grid_scrollable_preview;
+
+ setContentView(mLayoutId);
+ mTabHost = findViewById(com.android.internal.R.id.profile_tabhost);
+ mViewPager = requireViewById(com.android.internal.R.id.profile_pager);
+ mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager);
+ ChooserNestedScrollView scrollableContainer =
+ requireViewById(R.id.chooser_scrollable_container);
+ if (keyboardNavigationFix()) {
+ scrollableContainer.setRequestChildFocusPredicate((child, focused) ->
+ // TabHost view will request focus on the newly activated tab. The RecyclerView
+ // from the tab gets focused and notifies its parents (including
+ // NestedScrollView) about it through #requestChildFocus method call.
+ // NestedScrollView's view implementation of the method will scroll to the
+ // focused view. As we don't want to change drawer's position upon tab change,
+ // ignore focus requests from tab RecyclerViews.
+ focused == null || focused.getId() != com.android.internal.R.id.resolver_list);
+ }
+ boolean result = postRebuildList(rebuildCompleted);
+ Trace.endSection();
+ return result;
+ }
+
+ /**
+ * Finishing procedures to be performed after the list has been rebuilt.
+ * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
+ * @param rebuildCompleted
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ protected boolean postRebuildList(boolean rebuildCompleted) {
+ return postRebuildListInternal(rebuildCompleted);
}
+ /**
+ * Add a label to signify that the user can pick a different app.
+ * @param adapter The adapter used to provide data to item views.
+ */
+ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) {
+ final boolean useHeader = adapter.hasFilteredItem();
+ if (useHeader) {
+ FrameLayout stub = findViewById(com.android.internal.R.id.stub);
+ stub.setVisibility(View.VISIBLE);
+ TextView textView = (TextView) LayoutInflater.from(this).inflate(
+ R.layout.resolver_different_item_header, null, false);
+ if (mProfiles.getWorkProfilePresent()) {
+ textView.setGravity(Gravity.CENTER);
+ }
+ stub.addView(textView);
+ }
+ }
+ private void setupViewVisibilities() {
+ ChooserListAdapter activeListAdapter =
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter();
+ if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) {
+ addUseDifferentAppLabelIfNecessary(activeListAdapter);
+ }
+ }
+ /**
+ * Finishing procedures to be performed after the list has been rebuilt.
+ * @param rebuildCompleted
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ final boolean postRebuildListInternal(boolean rebuildCompleted) {
+ int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
+
+ // We only rebuild asynchronously when we have multiple elements to sort. In the case where
+ // we're already done, we can check if we should auto-launch immediately.
+ if (rebuildCompleted && maybeAutolaunchActivity()) {
+ return true;
+ }
+
+ setupViewVisibilities();
+
+ if (mProfiles.getWorkProfilePresent()
+ || (mProfiles.getPrivateProfilePresent()
+ && mProfileAvailability.isAvailable(
+ requireNonNull(mProfiles.getPrivateProfile())))) {
+ setupProfileTabs();
+ }
+
+ return false;
+ }
+
+ private void setupProfileTabs() {
+ mChooserMultiProfilePagerAdapter.setupProfileTabs(
+ getLayoutInflater(),
+ mTabHost,
+ mViewPager,
+ R.layout.resolver_profile_tab_button,
+ com.android.internal.R.id.profile_pager,
+ () -> onProfileTabSelected(mViewPager.getCurrentItem()),
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {}
+
+ @Override
+ public void onProfilePageStateChanged(int state) {
+ onHorizontalSwipeStateChanged(state);
+ }
+ });
+ mOnSwitchOnWorkSelectedListener = () -> {
+ View workTab = mTabHost.getTabWidget().getChildAt(
+ mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK));
+ workTab.setFocusable(true);
+ workTab.setFocusableInTouchMode(true);
+ workTab.requestFocus();
+ };
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////
+
private void createProfileRecords(
AppPredictorFactory factory, IntentFilter targetIntentFilter) {
- UserHandle mainUserHandle = getPersonalProfileUserHandle();
- ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory);
- if (record.shortcutLoader == null) {
- Tracer.INSTANCE.endLaunchToShortcutTrace();
- }
- UserHandle workUserHandle = getWorkProfileUserHandle();
- if (workUserHandle != null) {
- createProfileRecord(workUserHandle, targetIntentFilter, factory);
+ Profile launchedAsProfile = mProfiles.getLaunchedAsProfile();
+ for (Profile profile : mProfiles.getProfiles()) {
+ if (profile.getType() == Profile.Type.PRIVATE
+ && !mProfileAvailability.isAvailable(profile)) {
+ continue;
+ }
+ ProfileRecord record = createProfileRecord(
+ profile,
+ targetIntentFilter,
+ launchedAsProfile.equals(profile)
+ ? mRequest.getCallerChooserTargets()
+ : Collections.emptyList(),
+ factory);
+ if (profile.equals(launchedAsProfile) && record.shortcutLoader == null) {
+ Tracer.INSTANCE.endLaunchToShortcutTrace();
+ }
}
}
private ProfileRecord createProfileRecord(
- UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) {
+ Profile profile,
+ IntentFilter targetIntentFilter,
+ List<ChooserTarget> callerTargets,
+ AppPredictorFactory factory) {
+ UserHandle userHandle = profile.getPrimary().getHandle();
AppPredictor appPredictor = factory.create(userHandle);
ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic()
? null
: createShortcutLoader(
- getApplicationContext(),
+ this,
appPredictor,
userHandle,
targetIntentFilter,
shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult));
- ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader);
+ ProfileRecord record = new ProfileRecord(
+ profile, appPredictor, shortcutLoader, callerTargets);
mProfileRecords.put(userHandle.getIdentifier(), record);
return record;
}
@Nullable
private ProfileRecord getProfileRecord(UserHandle userHandle) {
- return mProfileRecords.get(userHandle.getIdentifier(), null);
+ return mProfileRecords.get(userHandle.getIdentifier());
}
@VisibleForTesting
@@ -413,7 +1488,7 @@ public class ChooserActivity extends ResolverActivity implements
Consumer<ShortcutLoader.Result> callback) {
return new ShortcutLoader(
context,
- getLifecycle(),
+ getCoroutineScope(getLifecycle()),
appPredictor,
userHandle,
targetIntentFilter,
@@ -421,147 +1496,71 @@ public class ChooserActivity extends ResolverActivity implements
}
static SharedPreferences getPinnedSharedPrefs(Context context) {
- // The code below is because in the android:ui process, no one can hear you scream.
- // The package info in the context isn't initialized in the way it is for normal apps,
- // so the standard, name-based context.getSharedPreferences doesn't work. Instead, we
- // build the path manually below using the same policy that appears in ContextImpl.
- // This fails silently under the hood if there's a problem, so if we find ourselves in
- // the case where we don't have access to credential encrypted storage we just won't
- // have our pinned target info.
- final File prefsFile = new File(new File(
- Environment.getDataUserCePackageDirectory(StorageManager.UUID_PRIVATE_INTERNAL,
- context.getUserId(), context.getPackageName()),
- "shared_prefs"),
- PINNED_SHARED_PREFS_NAME + ".xml");
- return context.getSharedPreferences(prefsFile, MODE_PRIVATE);
+ return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE);
}
- @Override
- protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- if (shouldShowTabs()) {
- mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, rList, filterLastUsed, targetDataLoader);
- } else {
- mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile(
- initialIntents, rList, filterLastUsed, targetDataLoader);
- }
- return mChooserMultiProfilePagerAdapter;
- }
+ private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter(
+ Context context,
+ ProfilePagerResources profilePagerResources,
+ ChooserRequest request,
+ ProfileHelper profileHelper,
+ Collection<ProfileRecord> profileRecords,
+ ProfileAvailability profileAvailability,
+ List<Intent> initialIntents,
+ int maxTargetsPerRow) {
+ Log.d(TAG, "createMultiProfilePagerAdapter");
+
+ Profile launchedAs = profileHelper.getLaunchedAsProfile();
+
+ Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]);
+ List<Intent> payloadIntents = request.getPayloadIntents();
+
+ List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>();
+ for (ProfileRecord record : profileRecords) {
+ Profile profile = record.profile;
+ ChooserGridAdapter adapter = createChooserGridAdapter(
+ context,
+ payloadIntents,
+ profile.equals(launchedAs) ? initialIntentArray : null,
+ profile.getPrimary().getHandle()
+ );
+ tabs.add(new TabConfig<>(
+ /* profile = */ profile.getType().ordinal(),
+ profilePagerResources.profileTabLabel(profile.getType()),
+ profilePagerResources.profileTabAccessibilityLabel(profile.getType()),
+ /* tabTag = */ profile.getType().name(),
+ adapter));
+ }
+
+ EmptyStateProvider emptyStateProvider =
+ createEmptyStateProvider(profileHelper, profileAvailability);
+
+ Supplier<Boolean> workProfileQuietModeChecker =
+ () -> !(profileHelper.getWorkProfilePresent()
+ && profileAvailability.isAvailable(
+ requireNonNull(profileHelper.getWorkProfile())));
- @Override
- protected EmptyStateProvider createBlockerEmptyStateProvider() {
- final boolean isSendAction = mChooserRequest.isSendActionTarget();
-
- final EmptyState noWorkToPersonalEmptyState =
- new DevicePolicyBlockerEmptyState(
- /* context= */ this,
- /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
- /* devicePolicyStringSubtitleId= */
- isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL,
- /* defaultSubtitleResource= */
- isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation
- : R.string.resolver_cant_access_personal_apps_explanation,
- /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
- /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER);
-
- final EmptyState noPersonalToWorkEmptyState =
- new DevicePolicyBlockerEmptyState(
- /* context= */ this,
- /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
- /* devicePolicyStringSubtitleId= */
- isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK,
- /* defaultSubtitleResource= */
- isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation
- : R.string.resolver_cant_access_work_apps_explanation,
- /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
- /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER);
-
- return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
- noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
- createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch());
- }
-
- private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- ChooserGridAdapter adapter = createChooserGridAdapter(
- /* context */ this,
- /* payloadIntents */ mIntents,
- initialIntents,
- rList,
- filterLastUsed,
- /* userHandle */ getPersonalProfileUserHandle(),
- targetDataLoader);
return new ChooserMultiProfilePagerAdapter(
/* context */ this,
- adapter,
- createEmptyStateProvider(/* workProfileUserHandle= */ null),
- /* workProfileQuietModeChecker= */ () -> false,
- /* workProfileUserHandle= */ null,
- getCloneProfileUserHandle(),
- mMaxTargetsPerRow);
+ ImmutableList.copyOf(tabs),
+ emptyStateProvider,
+ workProfileQuietModeChecker,
+ launchedAs.getType().ordinal(),
+ profileHelper.getWorkHandle(),
+ profileHelper.getCloneHandle(),
+ maxTargetsPerRow);
}
- private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles(
- Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- int selectedProfile = findSelectedProfile();
- ChooserGridAdapter personalAdapter = createChooserGridAdapter(
- /* context */ this,
- /* payloadIntents */ mIntents,
- selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
- rList,
- filterLastUsed,
- /* userHandle */ getPersonalProfileUserHandle(),
- targetDataLoader);
- ChooserGridAdapter workAdapter = createChooserGridAdapter(
- /* context */ this,
- /* payloadIntents */ mIntents,
- selectedProfile == PROFILE_WORK ? initialIntents : null,
- rList,
- filterLastUsed,
- /* userHandle */ getWorkProfileUserHandle(),
- targetDataLoader);
- return new ChooserMultiProfilePagerAdapter(
- /* context */ this,
- personalAdapter,
- workAdapter,
- createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()),
- () -> mWorkProfileAvailability.isQuietModeEnabled(),
- selectedProfile,
- getWorkProfileUserHandle(),
- getCloneProfileUserHandle(),
- mMaxTargetsPerRow);
+ protected EmptyStateProvider createBlockerEmptyStateProvider() {
+ return new NoCrossProfileEmptyStateProvider(
+ mProfiles,
+ mDevicePolicyResources,
+ createCrossProfileIntentsChecker(),
+ mRequest.isSendActionTarget());
}
private int findSelectedProfile() {
- int selectedProfile = getSelectedProfileExtra();
- if (selectedProfile == -1) {
- selectedProfile = getProfileForUser(getTabOwnerUserHandleForLaunch());
- }
- return selectedProfile;
- }
-
- @Override
- protected boolean postRebuildList(boolean rebuildCompleted) {
- updateStickyContentPreview();
- if (shouldShowStickyContentPreview()
- || mChooserMultiProfilePagerAdapter
- .getCurrentRootAdapter().getSystemRowCount() != 0) {
- getEventLog().logActionShareWithPreview(
- mChooserContentPreviewUi.getPreferredContentPreview());
- }
- return postRebuildListInternal(rebuildCompleted);
+ return mProfiles.getLaunchedAsProfileType().ordinal();
}
/**
@@ -569,12 +1568,11 @@ public class ChooserActivity extends ResolverActivity implements
* @return true if it is work profile, false if it is parent profile (or no work profile is
* set up)
*/
- protected boolean isWorkProfile() {
- return getSystemService(UserManager.class)
- .getUserInfo(UserHandle.myUserId()).isManagedProfile();
+ private boolean isWorkProfile() {
+ return mProfiles.getLaunchedAsProfileType() == Profile.Type.WORK;
}
- @Override
+ //@Override
protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) {
return new PackageMonitor() {
@Override
@@ -587,6 +1585,7 @@ public class ChooserActivity extends ResolverActivity implements
/**
* Update UI to reflect changes in data.
*/
+ @Override
public void handlePackagesChanged() {
handlePackagesChanged(/* listAdapter */ null);
}
@@ -599,45 +1598,38 @@ public class ChooserActivity extends ResolverActivity implements
private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) {
// Refresh pinned items
mPinnedSharedPrefs = getPinnedSharedPrefs(this);
- if (listAdapter == null) {
- handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter());
- if (mChooserMultiProfilePagerAdapter.getCount() > 1) {
- handlePackageChangePerProfile(
- mChooserMultiProfilePagerAdapter.getInactiveListAdapter());
- }
+ if (rebuildAdaptersOnTargetPinning()) {
+ recreatePagerAdapter();
} else {
- handlePackageChangePerProfile(listAdapter);
- }
- updateProfileViewButton();
- }
-
- private void handlePackageChangePerProfile(ResolverListAdapter adapter) {
- ProfileRecord record = getProfileRecord(adapter.getUserHandle());
- if (record != null && record.shortcutLoader != null) {
- record.shortcutLoader.reset();
+ if (listAdapter == null) {
+ mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs();
+ } else {
+ listAdapter.handlePackagesChanged();
+ }
}
- adapter.handlePackagesChanged();
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
- mFinishWhenStopped = false;
- mRefinementManager.onActivityResume();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
- ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
- if (viewPager.isLayoutRtl()) {
- mMultiProfilePagerAdapter.setupViewPager(viewPager);
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+
+ if (mSystemWindowInsets != null) {
+ int topSpacing = isInteractiveSession() ? getInteractiveSessionTopSpacing() : 0;
+ mResolverDrawerLayout.setPadding(
+ mSystemWindowInsets.left,
+ mSystemWindowInsets.top + topSpacing,
+ mSystemWindowInsets.right,
+ 0);
+ }
+ if (mViewPager.isLayoutRtl()) {
+ mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager);
}
mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation);
mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row);
mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow);
+ adjustMaxPreviewWidth();
adjustPreviewWidth(newConfig.orientation, null);
updateStickyContentPreview();
updateTabPadding();
@@ -650,6 +1642,14 @@ public class ChooserActivity extends ResolverActivity implements
return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode();
}
+ private void adjustMaxPreviewWidth() {
+ if (mResolverDrawerLayout == null) {
+ return;
+ }
+ mResolverDrawerLayout.setMaxWidth(
+ getResources().getDimensionPixelSize(R.dimen.chooser_width));
+ }
+
private void adjustPreviewWidth(int orientation, View parent) {
int width = -1;
if (mShouldDisplayLandscape) {
@@ -662,7 +1662,7 @@ public class ChooserActivity extends ResolverActivity implements
}
private void updateTabPadding() {
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
View tabs = findViewById(com.android.internal.R.id.tabs);
float iconSize = getResources().getDimension(R.dimen.chooser_icon_size);
// The entire width consists of icons or padding. Divide the item padding in half to get
@@ -693,7 +1693,8 @@ public class ChooserActivity extends ResolverActivity implements
ViewGroup layout = mChooserContentPreviewUi.displayContentPreview(
getResources(),
getLayoutInflater(),
- parent);
+ parent,
+ requireViewById(R.id.chooser_headline_row_container));
if (layout != null) {
adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
@@ -719,47 +1720,17 @@ public class ChooserActivity extends ResolverActivity implements
return resolver.query(uri, null, null, null, null);
}
- @Override
- protected void onStop() {
- super.onStop();
- mRefinementManager.onActivityStop(isChangingConfigurations());
-
- if (mFinishWhenStopped) {
- mFinishWhenStopped = false;
- finish();
- }
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
-
- if (isFinishing()) {
- mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
- }
-
- mBackgroundThreadPoolExecutor.shutdownNow();
-
- destroyProfileRecords();
- }
-
private void destroyProfileRecords() {
- for (int i = 0; i < mProfileRecords.size(); ++i) {
- mProfileRecords.valueAt(i).destroy();
- }
+ mProfileRecords.values().forEach(ProfileRecord::destroy);
mProfileRecords.clear();
}
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
- if (mChooserRequest == null) {
- return defIntent;
- }
-
Intent result = defIntent;
- if (mChooserRequest.getReplacementExtras() != null) {
+ if (mRequest.getReplacementExtras() != null) {
final Bundle replExtras =
- mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName);
+ mRequest.getReplacementExtras().getBundle(aInfo.packageName);
if (replExtras != null) {
result = new Intent(defIntent);
result.putExtras(replExtras);
@@ -778,60 +1749,45 @@ public class ChooserActivity extends ResolverActivity implements
return result;
}
- @Override
- public void onActivityStarted(TargetInfo cti) {
- if (mChooserRequest.getChosenComponentSender() != null) {
+ private void maybeSendShareResult(TargetInfo cti, UserHandle launchedAsUser) {
+ if (mShareResultSender != null) {
final ComponentName target = cti.getResolvedComponentName();
if (target != null) {
- final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target);
- try {
- mChooserRequest.getChosenComponentSender().sendIntent(
- this, Activity.RESULT_OK, fillIn, null, null);
- } catch (IntentSender.SendIntentException e) {
- Slog.e(TAG, "Unable to launch supplied IntentSender to report "
- + "the chosen component: " + e);
- }
+ boolean crossProfile = !UserHandle.of(UserHandle.myUserId()).equals(launchedAsUser);
+ mShareResultSender.onComponentSelected(
+ target, cti.isChooserTargetInfo(), crossProfile);
}
}
}
- private void addCallerChooserTargets() {
- if (!mChooserRequest.getCallerChooserTargets().isEmpty()) {
- // Send the caller's chooser targets only to the default profile.
- UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK)
- ? getAnnotatedUserHandles().workProfileUserHandle
- : getAnnotatedUserHandles().personalProfileUserHandle;
- if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) {
- mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
- /* origTarget */ null,
- new ArrayList<>(mChooserRequest.getCallerChooserTargets()),
- TARGET_TYPE_DEFAULT,
- /* directShareShortcutInfoCache */ Collections.emptyMap(),
- /* directShareAppTargetCache */ Collections.emptyMap());
- }
+ private void addCallerChooserTargets(ChooserListAdapter adapter) {
+ ProfileRecord record = getProfileRecord(adapter.getUserHandle());
+ List<ChooserTarget> callerTargets = record == null
+ ? Collections.emptyList()
+ : record.callerTargets;
+ if (!callerTargets.isEmpty()) {
+ adapter.addServiceResults(
+ /* origTarget */ null,
+ new ArrayList<>(mRequest.getCallerChooserTargets()),
+ TARGET_TYPE_DEFAULT,
+ /* directShareShortcutInfoCache */ Collections.emptyMap(),
+ /* directShareAppTargetCache */ Collections.emptyMap());
}
}
- @Override
- public int getLayoutResource() {
- return R.layout.chooser_grid;
- }
-
@Override // ResolverListCommunicator
public boolean shouldGetActivityMetadata() {
return true;
}
- @Override
public boolean shouldAutoLaunchSingleChoice(TargetInfo target) {
- // Note that this is only safe because the Intent handled by the ChooserActivity is
- // guaranteed to contain no extras unknown to the local ClassLoader. That is why this
- // method can not be replaced in the ResolverActivity whole hog.
- if (!super.shouldAutoLaunchSingleChoice(target)) {
+ if (target.isSuspended()) {
return false;
}
- return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true);
+ // TODO: migrate to ChooserRequest
+ return mViewModel.getActivityModel().getIntent()
+ .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true);
}
private void showTargetDetails(TargetInfo targetInfo) {
@@ -846,8 +1802,9 @@ public class ChooserActivity extends ResolverActivity implements
// TODO: implement these type-conditioned behaviors polymorphically, and consider moving
// the logic into `ChooserTargetActionsDialogFragment.show()`.
boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned();
- IntentFilter intentFilter = targetInfo.isSelectableTargetInfo()
- ? mChooserRequest.getTargetIntentFilter() : null;
+ IntentFilter intentFilter;
+ intentFilter = targetInfo.isSelectableTargetInfo()
+ ? mRequest.getShareTargetFilter() : null;
String shortcutTitle = targetInfo.isSelectableTargetInfo()
? targetInfo.getDisplayLabel().toString() : null;
String shortcutIdKey = targetInfo.getDirectShareShortcutId();
@@ -864,22 +1821,25 @@ public class ChooserActivity extends ResolverActivity implements
intentFilter);
}
- @Override
- protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
+ protected boolean onTargetSelected(TargetInfo target) {
if (mRefinementManager.maybeHandleSelection(
target,
- mChooserRequest.getRefinementIntentSender(),
+ mRequest.getRefinementIntentSender(),
getApplication(),
getMainThreadHandler())) {
return false;
}
updateModelAndChooserCounts(target);
maybeRemoveSharedText(target);
- return super.onTargetSelected(target, alwaysCheck);
+ safelyStartActivity(target);
+
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ return !target.isSuspended();
}
@Override
- public void startSelected(int which, boolean always, boolean filtered) {
+ public void startSelected(int which, /* unused */ boolean always, boolean filtered) {
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
TargetInfo targetInfo = currentListAdapter
@@ -902,8 +1862,24 @@ public class ChooserActivity extends ResolverActivity implements
return;
}
}
+ if (isFinishing()) {
+ return;
+ }
- super.startSelected(which, always, filtered);
+ TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
+ .targetInfoForPosition(which, filtered);
+ if (target != null) {
+ if (onTargetSelected(target)) {
+ MetricsLogger.action(
+ this, MetricsEvent.ACTION_APP_DISAMBIG_TAP);
+ MetricsLogger.action(this,
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
+ ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED
+ : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED);
+ Log.d(TAG, "onTargetSelected() returned true, finishing! " + target);
+ finish();
+ }
+ }
// TODO: both of the conditions around this switch logic *should* be redundant, and
// can be removed if certain invariants can be guaranteed. In particular, it seems
@@ -923,7 +1899,7 @@ public class ChooserActivity extends ResolverActivity implements
targetInfo.getResolveInfo().activityInfo.processName,
which,
/* directTargetAlsoRanked= */ getRankedPosition(targetInfo),
- mChooserRequest.getCallerChooserTargets().size(),
+ mRequest.getCallerChooserTargets().size(),
targetInfo.getHashedTargetIdForMetrics(this),
targetInfo.isPinned(),
mIsSuccessfullySelected,
@@ -960,7 +1936,6 @@ public class ChooserActivity extends ResolverActivity implements
mIsSuccessfullySelected,
selectionCost
);
- return;
}
}
}
@@ -982,19 +1957,8 @@ public class ChooserActivity extends ResolverActivity implements
return -1;
}
- @Override
- protected boolean shouldAddFooterView() {
- // To accommodate for window insets
- return true;
- }
-
- @Override
protected void applyFooterView(int height) {
- int count = mChooserMultiProfilePagerAdapter.getItemCount();
-
- for (int i = 0; i < count; i++) {
- mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height);
- }
+ mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height);
}
private void logDirectShareTargetReceived(UserHandle forUser) {
@@ -1014,7 +1978,7 @@ public class ChooserActivity extends ResolverActivity implements
if (info != null) {
sendClickToAppPredictor(info);
final ResolveInfo ri = info.getResolveInfo();
- Intent targetIntent = getTargetIntent();
+ Intent targetIntent = mRequest.getTargetIntent();
if (ri != null && ri.activityInfo != null && targetIntent != null) {
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
@@ -1037,12 +2001,12 @@ public class ChooserActivity extends ResolverActivity implements
mIsSuccessfullySelected = true;
}
- private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) {
+ private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) {
Intent targetIntent = targetInfo.getTargetIntent();
if (targetIntent == null) {
return;
}
- Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent());
+ Intent originalTargetIntent = new Intent(mRequest.getTargetIntent());
// Our TargetInfo implementations add associated component to the intent, let's do the same
// for the sake of the comparison below.
if (targetIntent.getComponent() != null) {
@@ -1112,103 +2076,36 @@ public class ChooserActivity extends ResolverActivity implements
ProfileRecord record = getProfileRecord(userHandle);
// We cannot use APS service when clone profile is present as APS service cannot sort
// cross profile targets as of now.
- return (record == null || getCloneProfileUserHandle() != null) ? null : record.appPredictor;
- }
-
- /**
- * Sort intents alphabetically based on display label.
- */
- static class AzInfoComparator implements Comparator<DisplayResolveInfo> {
- Comparator<DisplayResolveInfo> mComparator;
- AzInfoComparator(Context context) {
- Collator collator = Collator
- .getInstance(context.getResources().getConfiguration().locale);
- // Adding two stage comparator, first stage compares using displayLabel, next stage
- // compares using resolveInfo.userHandle
- mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator)
- .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier());
- }
-
- @Override
- public int compare(
- DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) {
- return mComparator.compare(lhsp, rhsp);
- }
+ return ((record == null) || (mProfiles.getCloneUserPresent()))
+ ? null : record.appPredictor;
}
protected EventLog getEventLog() {
- if (mEventLog == null) {
- mEventLog = new EventLog();
- }
return mEventLog;
}
- public class ChooserListController extends ResolverListController {
- public ChooserListController(
- Context context,
- PackageManager pm,
- Intent targetIntent,
- String referrerPackageName,
- int launchedFromUid,
- AbstractResolverComparator resolverComparator,
- UserHandle queryIntentsAsUser) {
- super(
- context,
- pm,
- targetIntent,
- referrerPackageName,
- launchedFromUid,
- resolverComparator,
- queryIntentsAsUser);
- }
-
- @Override
- boolean isComponentFiltered(ComponentName name) {
- return mChooserRequest.getFilteredComponentNames().contains(name);
- }
-
- @Override
- public boolean isComponentPinned(ComponentName name) {
- return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false);
- }
- }
-
- @VisibleForTesting
- public ChooserGridAdapter createChooserGridAdapter(
+ private ChooserGridAdapter createChooserGridAdapter(
Context context,
List<Intent> payloadIntents,
Intent[] initialIntents,
- List<ResolveInfo> rList,
- boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
+ UserHandle userHandle) {
ChooserListAdapter chooserListAdapter = createChooserListAdapter(
context,
payloadIntents,
initialIntents,
- rList,
- filterLastUsed,
+ /* TODO: not used, remove. rList= */ null,
+ /* TODO: not used, remove. filterLastUsed= */ false,
createListController(userHandle),
userHandle,
- getTargetIntent(),
- mChooserRequest,
- mMaxTargetsPerRow,
- targetDataLoader);
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerFillInIntent(),
+ mMaxTargetsPerRow
+ );
return new ChooserGridAdapter(
context,
new ChooserGridAdapter.ChooserActivityDelegate() {
@Override
- public boolean shouldShowTabs() {
- return ChooserActivity.this.shouldShowTabs();
- }
-
- @Override
- public View buildContentPreview(ViewGroup parent) {
- return createContentPreviewView(parent);
- }
-
- @Override
public void onTargetSelected(int itemIndex) {
startSelected(itemIndex, false, true);
}
@@ -1226,13 +2123,6 @@ public class ChooserActivity extends ResolverActivity implements
showTargetDetails(longPressedTargetInfo);
}
}
-
- @Override
- public void updateProfileViewButton(View newButtonFromProfileRow) {
- mProfileView = newButtonFromProfileRow;
- mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick);
- ChooserActivity.this.updateProfileViewButton();
- }
},
chooserListAdapter,
shouldShowContentPreview(),
@@ -1249,88 +2139,163 @@ public class ChooserActivity extends ResolverActivity implements
ResolverListController resolverListController,
UserHandle userHandle,
Intent targetIntent,
- ChooserRequestParameters chooserRequest,
- int maxTargetsPerRow,
- TargetDataLoader targetDataLoader) {
- UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile()
- && userHandle.equals(getPersonalProfileUserHandle())
- ? getCloneProfileUserHandle() : userHandle;
+ Intent referrerFillInIntent,
+ int maxTargetsPerRow) {
+ UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle);
return new ChooserListAdapter(
context,
payloadIntents,
initialIntents,
rList,
filterLastUsed,
- createListController(userHandle),
+ resolverListController,
userHandle,
targetIntent,
+ referrerFillInIntent,
this,
- context.getPackageManager(),
+ mPackageManager,
getEventLog(),
- chooserRequest,
maxTargetsPerRow,
initialIntentsUserSpace,
- targetDataLoader);
+ mTargetDataLoader,
+ () -> {
+ ProfileRecord record = getProfileRecord(userHandle);
+ if (record != null && record.shortcutLoader != null) {
+ record.shortcutLoader.reset();
+ }
+ });
}
- @Override
- protected void onWorkProfileStatusUpdated() {
- UserHandle workUser = getWorkProfileUserHandle();
+ private void onWorkProfileStatusUpdated() {
+ UserHandle workUser = mProfiles.getWorkHandle();
ProfileRecord record = workUser == null ? null : getProfileRecord(workUser);
if (record != null && record.shortcutLoader != null) {
record.shortcutLoader.reset();
}
- super.onWorkProfileStatusUpdated();
+ if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals(
+ mProfiles.getWorkHandle())) {
+ mChooserMultiProfilePagerAdapter.rebuildActiveTab(true);
+ } else {
+ mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
}
- @Override
@VisibleForTesting
protected ChooserListController createListController(UserHandle userHandle) {
AppPredictor appPredictor = getAppPredictor(userHandle);
AbstractResolverComparator resolverComparator;
if (appPredictor != null) {
- resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(),
- getReferrerPackageName(), appPredictor, userHandle, getEventLog(),
- getIntegratedDeviceComponents().getNearbySharingComponent());
+ resolverComparator = new AppPredictionServiceResolverComparator(
+ this,
+ mRequest.getTargetIntent(),
+ mRequest.getLaunchedFromPackage(),
+ appPredictor,
+ userHandle,
+ getEventLog(),
+ mNearbyShare.orElse(null)
+ );
} else {
resolverComparator =
new ResolverRankerServiceResolverComparator(
this,
- getTargetIntent(),
- getReferrerPackageName(),
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerPackage(),
null,
getEventLog(),
getResolverRankerServiceUserHandleList(userHandle),
- getIntegratedDeviceComponents().getNearbySharingComponent());
+ mNearbyShare.orElse(null));
}
return new ChooserListController(
this,
- mPm,
- getTargetIntent(),
- getReferrerPackageName(),
- getAnnotatedUserHandles().userIdOfCallingApp,
+ mPackageManager,
+ mRequest.getTargetIntent(),
+ mRequest.getReferrerPackage(),
+ mViewModel.getActivityModel().getLaunchedFromUid(),
resolverComparator,
- getQueryIntentsUser(userHandle));
+ mProfiles.getQueryIntentsHandle(userHandle),
+ mRequest.getFilteredComponentNames(),
+ mPinnedSharedPrefs);
}
- @VisibleForTesting
- protected ViewModelProvider.Factory createPreviewViewModelFactory() {
- return PreviewViewModel.Companion.getFactory();
+ private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement(
+ ChooserContentPreviewUi.ActionFactory originalFactory) {
+ if (!refineSystemActions()) {
+ return originalFactory;
+ }
+
+ return new ChooserContentPreviewUi.ActionFactory() {
+ @Override
+ @Nullable
+ public Runnable getEditButtonRunnable() {
+ if (originalFactory.getEditButtonRunnable() == null) return null;
+ return () -> {
+ if (!mRefinementManager.maybeHandleSelection(
+ RefinementType.EDIT_ACTION,
+ List.of(mRequest.getTargetIntent()),
+ null,
+ mRequest.getRefinementIntentSender(),
+ getApplication(),
+ getMainThreadHandler())) {
+ originalFactory.getEditButtonRunnable().run();
+ }
+ };
+ }
+
+ @Override
+ @Nullable
+ public Runnable getCopyButtonRunnable() {
+ if (originalFactory.getCopyButtonRunnable() == null) return null;
+ return () -> {
+ if (!mRefinementManager.maybeHandleSelection(
+ RefinementType.COPY_ACTION,
+ List.of(mRequest.getTargetIntent()),
+ null,
+ mRequest.getRefinementIntentSender(),
+ getApplication(),
+ getMainThreadHandler())) {
+ originalFactory.getCopyButtonRunnable().run();
+ }
+ };
+ }
+
+ @Override
+ public List<ActionRow.Action> createCustomActions() {
+ return originalFactory.createCustomActions();
+ }
+
+ @Override
+ @Nullable
+ public ActionRow.Action getModifyShareAction() {
+ return originalFactory.getModifyShareAction();
+ }
+
+ @Override
+ public Consumer<Boolean> getExcludeSharedTextAction() {
+ return originalFactory.getExcludeSharedTextAction();
+ }
+ };
}
- private ChooserActionFactory createChooserActionFactory() {
+ private ChooserActionFactory createChooserActionFactory(Intent targetIntent) {
return new ChooserActionFactory(
this,
- mChooserRequest,
- mIntegratedDeviceComponents,
+ targetIntent,
+ mRequest.getLaunchedFromPackage(),
+ mRequest.getChooserActions(),
+ mImageEditor,
getEventLog(),
(isExcluded) -> mExcludeSharedText = isExcluded,
this::getFirstVisibleImgPreviewView,
new ChooserActionFactory.ActionActivityStarter() {
@Override
public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
- safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle());
+ safelyStartActivityAsUser(
+ targetInfo,
+ mProfiles.getPersonalHandle()
+ );
+ Log.d(TAG, "safelyStartActivityAsPersonalProfileUser("
+ + targetInfo + "): finishing!");
finish();
}
@@ -1340,19 +2305,34 @@ public class ChooserActivity extends ResolverActivity implements
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
ChooserActivity.this, sharedElement, sharedElementName);
safelyStartActivityAsUser(
- targetInfo, getPersonalProfileUserHandle(), options.toBundle());
+ targetInfo,
+ mProfiles.getPersonalHandle(),
+ options.toBundle());
// Can't finish right away because the shared element transition may not
// be ready to start.
mFinishWhenStopped = true;
-
}
},
- (status) -> {
- if (status != null) {
- setResult(status);
- }
- finish();
- });
+ mShareResultSender,
+ this::finishWithStatus,
+ mClipboardManager);
+ }
+
+ private Supplier<ActionRow.Action> createModifyShareActionFactory() {
+ return () -> ChooserActionFactory.createCustomAction(
+ ChooserActivity.this,
+ mRequest.getModifyShareAction(),
+ () -> getEventLog().logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE),
+ mShareResultSender,
+ this::finishWithStatus);
+ }
+
+ private void finishWithStatus(@Nullable Integer status) {
+ if (status != null) {
+ setResult(status);
+ }
+ Log.d(TAG, "finishWithStatus: result=" + status);
+ finish();
}
/*
@@ -1362,7 +2342,7 @@ public class ChooserActivity extends ResolverActivity implements
*/
private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
int oldTop, int oldRight, int oldBottom) {
- if (mChooserMultiProfilePagerAdapter == null) {
+ if (mChooserMultiProfilePagerAdapter == null || !isProfilePagerAdapterAttached()) {
return;
}
RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView();
@@ -1375,58 +2355,47 @@ public class ChooserActivity extends ResolverActivity implements
}
final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight();
+ final int maxChooserWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width);
boolean isLayoutUpdated =
- gridAdapter.calculateChooserTargetWidth(availableWidth)
+ gridAdapter.calculateChooserTargetWidth(
+ maxChooserWidth >= 0
+ ? Math.min(maxChooserWidth, availableWidth)
+ : availableWidth)
|| recyclerView.getAdapter() == null
|| availableWidth != mCurrAvailableWidth;
- boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets);
-
- if (isLayoutUpdated
- || insetsChanged
- || mLastNumberOfChildren != recyclerView.getChildCount()) {
- mCurrAvailableWidth = availableWidth;
- if (isLayoutUpdated) {
- // It is very important we call setAdapter from here. Otherwise in some cases
- // the resolver list doesn't get populated, such as b/150922090, b/150918223
- // and b/150936654
- recyclerView.setAdapter(gridAdapter);
- ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount(
- mMaxTargetsPerRow);
-
- updateTabPadding();
- }
+ mCurrAvailableWidth = availableWidth;
+ if (isLayoutUpdated) {
+ // It is very important we call setAdapter from here. Otherwise in some cases
+ // the resolver list doesn't get populated, such as b/150922090, b/150918223
+ // and b/150936654
+ recyclerView.setAdapter(gridAdapter);
+ ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount(
+ mMaxTargetsPerRow);
- UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle();
- int currentProfile = getProfileForUser(currentUserHandle);
- int initialProfile = findSelectedProfile();
- if (currentProfile != initialProfile) {
- return;
- }
+ updateTabPadding();
+ }
+
+ if (mChooserMultiProfilePagerAdapter.getActiveProfile() != mInitialProfile) {
+ return;
+ }
- if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) {
+ getMainThreadHandler().post(() -> {
+ if (mResolverDrawerLayout == null || gridAdapter == null) {
return;
}
-
- getMainThreadHandler().post(() -> {
- if (mResolverDrawerLayout == null || gridAdapter == null) {
- return;
- }
- int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter);
- mResolverDrawerLayout.setCollapsibleHeightReserved(offset);
- mEnterTransitionAnimationDelegate.markOffsetCalculated();
- mLastAppliedInsets = mSystemWindowInsets;
- });
- }
+ int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter);
+ mResolverDrawerLayout.setCollapsibleHeightReserved(offset);
+ mEnterTransitionAnimationDelegate.markOffsetCalculated();
+ mLastAppliedInsets = mSystemWindowInsets;
+ });
}
private int calculateDrawerOffset(
int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) {
int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
- int rowsToShow = gridAdapter.getSystemRowCount()
- + gridAdapter.getProfileRowCount()
- + gridAdapter.getServiceTargetRowCount()
+ int rowsToShow = gridAdapter.getServiceTargetRowCount()
+ gridAdapter.getCallerAndRankedTargetRowCount();
// then this is most likely not a SEND_* action, so check
@@ -1448,7 +2417,7 @@ public class ChooserActivity extends ResolverActivity implements
offset += stickyContentPreview.getHeight();
}
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
offset += findViewById(com.android.internal.R.id.tabs).getHeight();
}
@@ -1471,7 +2440,8 @@ public class ChooserActivity extends ResolverActivity implements
rowsToShow--;
}
} else {
- ViewGroup currentEmptyStateView = getActiveEmptyStateView();
+ ViewGroup currentEmptyStateView =
+ mChooserMultiProfilePagerAdapter.getActiveEmptyStateView();
if (currentEmptyStateView.getVisibility() == View.VISIBLE) {
offset += currentEmptyStateView.getHeight();
}
@@ -1480,82 +2450,73 @@ public class ChooserActivity extends ResolverActivity implements
return Math.min(offset, bottom - top);
}
+ private boolean isProfilePagerAdapterAttached() {
+ return mChooserMultiProfilePagerAdapter == mViewPager.getAdapter();
+ }
+
/**
* If we have a tabbed view and are showing 1 row in the current profile and an empty
- * state screen in the other profile, to prevent cropping of the empty state screen we show
+ * state screen in another profile, to prevent cropping of the empty state screen we show
* a second row in the current profile.
*/
private boolean shouldShowExtraRow(int rowsToShow) {
- return shouldShowTabs()
- && rowsToShow == 1
- && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(
- mChooserMultiProfilePagerAdapter.getInactiveListAdapter());
- }
-
- /**
- * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle.
- * Returns {@link #PROFILE_PERSONAL}, otherwise.
- **/
- private int getProfileForUser(UserHandle currentUserHandle) {
- if (currentUserHandle.equals(getWorkProfileUserHandle())) {
- return PROFILE_WORK;
- }
- // We return personal profile, as it is the default when there is no work profile, personal
- // profile represents rootUser, clonedUser & secondaryUser, covering all use cases.
- return PROFILE_PERSONAL;
- }
-
- private ViewGroup getActiveEmptyStateView() {
- int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage();
- return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage);
+ return rowsToShow == 1
+ && mChooserMultiProfilePagerAdapter
+ .shouldShowEmptyStateScreenInAnyInactiveAdapter();
}
- @Override // ResolverListCommunicator
- public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
- mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged();
- super.onHandlePackagesChanged(listAdapter);
- }
-
- @Override
- public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) {
+ protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) {
+ Log.d(TAG, "onListRebuilt(listAdapter.userHandle=" + listAdapter.getUserHandle() + ", "
+ + "rebuildComplete=" + rebuildComplete + ")");
setupScrollListener();
maybeSetupGlobalLayoutListener();
ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter;
- if (chooserListAdapter.getUserHandle()
- .equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) {
+ UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle();
+ if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) {
mChooserMultiProfilePagerAdapter.getActiveAdapterView()
.setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter());
mChooserMultiProfilePagerAdapter
.setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage());
}
+ //TODO: move this block inside ChooserListAdapter (should be called when
+ // ResolverListAdapter#mPostListReadyRunnable is executed.
if (chooserListAdapter.getDisplayResolveInfoCount() == 0) {
+ Log.d(TAG, "getDisplayResolveInfoCount() == 0");
+ if (rebuildComplete) {
+ onAppTargetsLoaded(listAdapter);
+ }
chooserListAdapter.notifyDataSetChanged();
} else {
- chooserListAdapter.updateAlphabeticalList();
+ chooserListAdapter.updateAlphabeticalList(() -> onAppTargetsLoaded(listAdapter));
}
if (rebuildComplete) {
- long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listAdapter.getUserHandle());
+ long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle);
if (duration >= 0) {
Log.d(TAG, "app target loading time " + duration + " ms");
}
- addCallerChooserTargets();
+ if (!fixShortcutsFlashingFixed()) {
+ addCallerChooserTargets(chooserListAdapter);
+ }
getEventLog().logSharesheetAppLoadComplete();
- maybeQueryAdditionalPostProcessingTargets(chooserListAdapter);
+ maybeQueryAdditionalPostProcessingTargets(
+ listProfileUserHandle,
+ chooserListAdapter.getDisplayResolveInfos());
mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET);
}
}
- private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) {
- UserHandle userHandle = chooserListAdapter.getUserHandle();
+ private void maybeQueryAdditionalPostProcessingTargets(
+ UserHandle userHandle,
+ DisplayResolveInfo[] displayResolveInfos) {
ProfileRecord record = getProfileRecord(userHandle);
if (record == null || record.shortcutLoader == null) {
return;
}
record.loadingStartTime = SystemClock.elapsedRealtime();
- record.shortcutLoader.updateAppTargets(chooserListAdapter.getDisplayResolveInfos());
+ record.shortcutLoader.updateAppTargets(displayResolveInfos);
}
@MainThread
@@ -1568,6 +2529,11 @@ public class ChooserActivity extends ResolverActivity implements
ChooserListAdapter adapter =
mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle);
if (adapter != null) {
+ if (fixShortcutsFlashingFixed()) {
+ adapter.setDirectTargetsEnabled(true);
+ adapter.resetDirectTargets();
+ addCallerChooserTargets(adapter);
+ }
for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) {
adapter.addServiceResults(
resultInfo.getAppTarget(),
@@ -1581,7 +2547,7 @@ public class ChooserActivity extends ResolverActivity implements
adapter.completeServiceTargetLoading();
}
- if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {
long duration = Tracer.INSTANCE.endLaunchToShortcutTrace();
if (duration >= 0) {
Log.d(TAG, "stat to first shortcut time: " + duration + " ms");
@@ -1596,13 +2562,15 @@ public class ChooserActivity extends ResolverActivity implements
if (mResolverDrawerLayout == null) {
return;
}
- int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header;
+ int elevatedViewResId = mProfiles.getWorkProfilePresent()
+ ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header;
final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId);
final float defaultElevation = elevatedView.getElevation();
final float chooserHeaderScrollElevation =
getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation);
mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener(
new RecyclerView.OnScrollListener() {
+ @Override
public void onScrollStateChanged(RecyclerView view, int scrollState) {
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) {
@@ -1617,6 +2585,7 @@ public class ChooserActivity extends ResolverActivity implements
}
}
+ @Override
public void onScrolled(RecyclerView view, int dx, int dy) {
if (view.getChildCount() > 0) {
View child = view.getLayoutManager().findViewByPosition(0);
@@ -1632,7 +2601,7 @@ public class ChooserActivity extends ResolverActivity implements
}
private void maybeSetupGlobalLayoutListener() {
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
return;
}
final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView();
@@ -1663,11 +2632,13 @@ public class ChooserActivity extends ResolverActivity implements
}
private boolean shouldShowStickyContentPreviewNoOrientationCheck() {
- return shouldShowTabs()
- && (mMultiProfilePagerAdapter.getListAdapterForUserHandle(
- UserHandle.of(UserHandle.myUserId())).getCount() > 0
- || shouldShowContentPreviewWhenEmpty())
- && shouldShowContentPreview();
+ if (isInteractiveSession() || !shouldShowContentPreview()) {
+ return false;
+ }
+ ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ UserHandle.of(UserHandle.myUserId()));
+ boolean isEmpty = adapter == null || adapter.getCount() == 0;
+ return !isEmpty || shouldShowContentPreviewWhenEmpty();
}
/**
@@ -1685,7 +2656,7 @@ public class ChooserActivity extends ResolverActivity implements
* @return true if we want to show the content preview area
*/
protected boolean shouldShowContentPreview() {
- return (mChooserRequest != null) && mChooserRequest.isSendActionTarget();
+ return mRequest.isSendActionTarget();
}
private void updateStickyContentPreview() {
@@ -1729,34 +2700,22 @@ public class ChooserActivity extends ResolverActivity implements
contentPreviewContainer.setVisibility(View.GONE);
}
- private View findRootView() {
- if (mContentView == null) {
- mContentView = findViewById(android.R.id.content);
- }
- return mContentView;
- }
-
- /**
- * Intentionally override the {@link ResolverActivity} implementation as we only need that
- * implementation for the intent resolver case.
- */
- @Override
- public void onButtonClick(View v) {}
-
- /**
- * Intentionally override the {@link ResolverActivity} implementation as we only need that
- * implementation for the intent resolver case.
- */
- @Override
- protected void resetButtonBar() {}
-
- @Override
protected String getMetricsCategory() {
return METRICS_CATEGORY_CHOOSER;
}
- @Override
- protected void onProfileTabSelected() {
+ protected void onProfileTabSelected(int currentPage) {
+ setupViewVisibilities();
+ maybeLogProfileChange();
+ if (mProfiles.getWorkProfilePresent()) {
+ // The device policy logger is only concerned with sessions that include a work profile.
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
+ .setInt(currentPage)
+ .setStrings(getMetricsCategory())
+ .write();
+ }
+
// This fixes an edge case where after performing a variety of gestures, vertical scrolling
// ends up disabled. That's because at some point the old tab's vertical scrolling is
// disabled and the new tab's is enabled. For context, see b/159997845
@@ -1766,25 +2725,39 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- @Override
+ private int getInteractiveSessionTopSpacing() {
+ return getResources().getDimensionPixelSize(R.dimen.chooser_preview_image_height_tall);
+ }
+
+ private boolean isInteractiveSession() {
+ return interactiveSession() && mRequest.getInteractiveSessionCallback() != null
+ && !isTaskRoot();
+ }
+
protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
- if (shouldShowTabs()) {
- mChooserMultiProfilePagerAdapter
- .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom());
- mChooserMultiProfilePagerAdapter.setupContainerPadding(
- getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container));
- }
+ mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars());
+ mChooserMultiProfilePagerAdapter
+ .setEmptyStateBottomOffset(mSystemWindowInsets.bottom);
+
+ final int topSpacing = isInteractiveSession() ? getInteractiveSessionTopSpacing() : 0;
+ mResolverDrawerLayout.setPadding(
+ mSystemWindowInsets.left,
+ mSystemWindowInsets.top + topSpacing,
+ mSystemWindowInsets.right,
+ 0);
+
+ // Need extra padding so the list can fully scroll up
+ // To accommodate for window insets
+ applyFooterView(mSystemWindowInsets.bottom);
- WindowInsets result = super.onApplyWindowInsets(v, insets);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.requestLayout();
}
- return result;
+ return WindowInsets.CONSUMED;
}
private void setHorizontalScrollingEnabled(boolean enabled) {
- ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
- viewPager.setSwipingEnabled(enabled);
+ mViewPager.setSwipingEnabled(enabled);
}
private void setVerticalScrollEnabled(boolean enabled) {
@@ -1794,7 +2767,6 @@ public class ChooserActivity extends ResolverActivity implements
layoutManager.setVerticalScrollEnabled(enabled);
}
- @Override
void onHorizontalSwipeStateChanged(int state) {
if (state == ViewPager.SCROLL_STATE_DRAGGING) {
if (mScrollStatus == SCROLL_STATUS_IDLE) {
@@ -1809,12 +2781,13 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- @Override
protected void maybeLogProfileChange() {
getEventLog().logSharesheetProfileChanged();
}
private static class ProfileRecord {
+ public final Profile profile;
+
/** The {@link AppPredictor} for this profile, if any. */
@Nullable
public final AppPredictor appPredictor;
@@ -1823,19 +2796,27 @@ public class ChooserActivity extends ResolverActivity implements
*/
@Nullable
public final ShortcutLoader shortcutLoader;
+ public final List<ChooserTarget> callerTargets;
public long loadingStartTime;
private ProfileRecord(
+ Profile profile,
@Nullable AppPredictor appPredictor,
- @Nullable ShortcutLoader shortcutLoader) {
+ @Nullable ShortcutLoader shortcutLoader,
+ List<ChooserTarget> callerTargets) {
+ this.profile = profile;
this.appPredictor = appPredictor;
this.shortcutLoader = shortcutLoader;
+ this.callerTargets = callerTargets;
}
public void destroy() {
if (appPredictor != null) {
appPredictor.destroy();
}
+ if (shortcutLoader != null) {
+ shortcutLoader.destroy();
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
index 5f373525..5bbb6c24 100644
--- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
+++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
@@ -16,18 +16,35 @@
package com.android.intentresolver;
+import static com.android.intentresolver.Flags.announceShortcutsAndSuggestedApps;
+
import android.content.Context;
import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.GridView;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
+import com.android.intentresolver.grid.ChooserGridAdapter;
+
/**
* For a11y and per {@link RecyclerView#onInitializeAccessibilityNodeInfo}, override
* methods to ensure proper row counts.
*/
public class ChooserGridLayoutManager extends GridLayoutManager {
+ private CharSequence mShortcutGroupTitle = "";
+ private CharSequence mSuggestedAppsGroupTitle = "";
+ private CharSequence mAllAppListGroupTitle = "";
+ @Nullable
+ private RecyclerView mRecyclerView;
private boolean mVerticalScrollEnabled = true;
/**
@@ -39,6 +56,9 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
public ChooserGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
+ if (announceShortcutsAndSuggestedApps()) {
+ readGroupTitles(context);
+ }
}
/**
@@ -49,6 +69,9 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
*/
public ChooserGridLayoutManager(Context context, int spanCount) {
super(context, spanCount);
+ if (announceShortcutsAndSuggestedApps()) {
+ readGroupTitles(context);
+ }
}
/**
@@ -61,6 +84,27 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
public ChooserGridLayoutManager(Context context, int spanCount, int orientation,
boolean reverseLayout) {
super(context, spanCount, orientation, reverseLayout);
+ if (announceShortcutsAndSuggestedApps()) {
+ readGroupTitles(context);
+ }
+ }
+
+ private void readGroupTitles(Context context) {
+ mShortcutGroupTitle = context.getString(R.string.shortcut_group_a11y_title);
+ mSuggestedAppsGroupTitle = context.getString(R.string.suggested_apps_group_a11y_title);
+ mAllAppListGroupTitle = context.getString(R.string.all_apps_group_a11y_title);
+ }
+
+ @Override
+ public void onAttachedToWindow(RecyclerView view) {
+ super.onAttachedToWindow(view);
+ mRecyclerView = view;
+ }
+
+ @Override
+ public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) {
+ super.onDetachedFromWindow(view, recycler);
+ mRecyclerView = null;
}
@Override
@@ -70,7 +114,7 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
return super.getRowCountForAccessibility(recycler, state) - 1;
}
- void setVerticalScrollEnabled(boolean verticalScrollEnabled) {
+ public void setVerticalScrollEnabled(boolean verticalScrollEnabled) {
mVerticalScrollEnabled = verticalScrollEnabled;
}
@@ -78,4 +122,91 @@ public class ChooserGridLayoutManager extends GridLayoutManager {
public boolean canScrollVertically() {
return mVerticalScrollEnabled && super.canScrollVertically();
}
+
+ @Override
+ public void onInitializeAccessibilityNodeInfoForItem(
+ RecyclerView.Recycler recycler,
+ RecyclerView.State state,
+ View host,
+ AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfoForItem(recycler, state, host, info);
+ if (announceShortcutsAndSuggestedApps() && host instanceof ViewGroup) {
+ if (host.getId() == R.id.shortcuts_container) {
+ info.setClassName(GridView.class.getName());
+ info.setContainerTitle(mShortcutGroupTitle);
+ info.setCollectionInfo(createShortcutsA11yCollectionInfo((ViewGroup) host));
+ } else if (host.getId() == R.id.suggested_apps_container) {
+ RecyclerView.Adapter adapter =
+ mRecyclerView == null ? null : mRecyclerView.getAdapter();
+ ChooserListAdapter gridAdapter = adapter instanceof ChooserGridAdapter
+ ? ((ChooserGridAdapter) adapter).getListAdapter()
+ : null;
+ info.setClassName(GridView.class.getName());
+ info.setCollectionInfo(createSuggestedAppsA11yCollectionInfo((ViewGroup) host));
+ if (gridAdapter == null || gridAdapter.getAlphaTargetCount() > 0) {
+ info.setContainerTitle(mSuggestedAppsGroupTitle);
+ } else {
+ // if all applications fit into one row, they will be put into the suggested
+ // applications group.
+ info.setContainerTitle(mAllAppListGroupTitle);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(@NonNull RecyclerView.Recycler recycler,
+ @NonNull RecyclerView.State state, @NonNull AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(recycler, state, info);
+ if (announceShortcutsAndSuggestedApps()) {
+ info.setContainerTitle(mAllAppListGroupTitle);
+ }
+ }
+
+ @Override
+ public boolean isLayoutHierarchical(
+ @NonNull RecyclerView.Recycler recycler, @NonNull RecyclerView.State state) {
+ return announceShortcutsAndSuggestedApps() || super.isLayoutHierarchical(recycler, state);
+ }
+
+ private CollectionInfoCompat createShortcutsA11yCollectionInfo(ViewGroup container) {
+ // TODO: create a custom view for the shortcuts row and move this logic there.
+ int rowCount = 0;
+ int columnCount = 0;
+ for (int i = 0; i < container.getChildCount(); i++) {
+ View row = container.getChildAt(i);
+ int rowColumnCount = 0;
+ if (row instanceof ViewGroup rowGroup && row.getVisibility() == View.VISIBLE) {
+ for (int j = 0; j < rowGroup.getChildCount(); j++) {
+ View v = rowGroup.getChildAt(j);
+ if (v != null && v.getVisibility() == View.VISIBLE) {
+ rowColumnCount++;
+ if (v instanceof TextView) {
+ // A special case of the no-targets message that also contains an
+ // off-screen item (which looks like a bug).
+ rowColumnCount = 1;
+ break;
+ }
+ }
+ }
+ }
+ if (rowColumnCount > 0) {
+ rowCount++;
+ columnCount = Math.max(columnCount, rowColumnCount);
+ }
+ }
+ return CollectionInfoCompat.obtain(rowCount, columnCount, false);
+ }
+
+ private CollectionInfoCompat createSuggestedAppsA11yCollectionInfo(ViewGroup container) {
+ // TODO: create a custom view for the suggested apps row and move this logic there.
+ int columnCount = 0;
+ for (int i = 0; i < container.getChildCount(); i++) {
+ View v = container.getChildAt(i);
+ if (v.getVisibility() == View.VISIBLE) {
+ columnCount++;
+ }
+ }
+ return CollectionInfoCompat.obtain(1, columnCount, false);
+ }
}
diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt
new file mode 100644
index 00000000..2d015128
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserHelper.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.app.Activity
+import android.os.UserHandle
+import android.provider.Settings
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.viewModels
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.intentresolver.Flags.interactiveSession
+import com.android.intentresolver.Flags.unselectFinalItem
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.platform.GlobalSettings
+import com.android.intentresolver.ui.viewmodel.ChooserViewModel
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.log
+import dagger.hilt.android.scopes.ActivityScoped
+import java.util.function.Consumer
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+private const val TAG: String = "ChooserHelper"
+
+/**
+ * __Purpose__
+ *
+ * Cleanup aid. Provides a pathway to cleaner code.
+ *
+ * __Incoming References__
+ *
+ * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a
+ * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer
+ * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at
+ * the appropriate point. This enforces unidirectional control flow.
+ *
+ * __Outgoing References__
+ *
+ * _ChooserActivity_
+ *
+ * This class must only reference it's host as Activity/ComponentActivity; no down-cast to
+ * [ChooserActivity]. Other components should be created here or supplied via Injection, and not
+ * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If
+ * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described
+ * above in 'Incoming References', see [ChooserInitializer].
+ *
+ * _Elsewhere_
+ *
+ * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of
+ * referenced from an existing location. If not available for injection, the value should be
+ * constructed here, then provided to where it is needed.
+ */
+@ActivityScoped
+@JavaInterop
+class ChooserHelper
+@Inject
+constructor(
+ hostActivity: Activity,
+ private val activityResultRepo: ActivityResultRepository,
+ private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository,
+ private val globalSettings: GlobalSettings,
+) : DefaultLifecycleObserver {
+ // This is guaranteed by Hilt, since only a ComponentActivity is injectable.
+ private val activity: ComponentActivity = hostActivity as ComponentActivity
+ private val viewModel by activity.viewModels<ChooserViewModel>()
+
+ // TODO: provide the following through an init object passed into [setInitialize]
+ private lateinit var activityInitializer: Runnable
+ /** Invoked when there are updates to ChooserRequest */
+ var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {}
+ /** Invoked when there are a new change to payload selection */
+ var onPendingSelection: Runnable = Runnable {}
+ var onHasSelections: Consumer<Boolean> = Consumer {}
+
+ init {
+ activity.lifecycle.addObserver(this)
+ }
+
+ /**
+ * Set the initialization hook for the host activity.
+ *
+ * This _must_ be called from [ChooserActivity.onCreate].
+ */
+ fun setInitializer(initializer: Runnable) {
+ check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) {
+ "setInitializer must be called before onCreate returns"
+ }
+ activityInitializer = initializer
+ }
+
+ /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */
+ override fun onCreate(owner: LifecycleOwner) {
+ Log.i(TAG, "CREATE")
+ Log.i(TAG, "${viewModel.activityModel}")
+
+ val callerUid: Int = viewModel.activityModel.launchedFromUid
+ if (callerUid < 0 || UserHandle.isIsolated(callerUid)) {
+ Log.e(TAG, "Can't start a chooser from uid $callerUid")
+ activity.finish()
+ return
+ }
+
+ if (globalSettings.getBooleanOrNull(Settings.Global.SECURE_FRP_MODE) == true) {
+ Log.e(TAG, "Sharing disabled due to active FRP lock.")
+ activity.finish()
+ return
+ }
+
+ when (val request = viewModel.initialRequest) {
+ is Valid -> initializeActivity(request)
+ is Invalid -> reportErrorsAndFinish(request)
+ }
+
+ activity.lifecycleScope.launch {
+ activity.setResult(activityResultRepo.activityResult.filterNotNull().first())
+ activity.finish()
+ }
+
+ activity.lifecycleScope.launch {
+ val hasPendingIntentFlow =
+ pendingSelectionCallbackRepo.pendingTargetIntent
+ .map { it != null }
+ .distinctUntilChanged()
+ .onEach { hasPendingIntent ->
+ if (hasPendingIntent) {
+ onPendingSelection.run()
+ }
+ }
+ activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ val hasSelectionFlow =
+ if (
+ unselectFinalItem() &&
+ viewModel.previewDataProvider.previewType ==
+ CONTENT_PREVIEW_PAYLOAD_SELECTION
+ ) {
+ viewModel.shareouselViewModel.hasSelectedItems.stateIn(scope = this).also {
+ flow ->
+ launch { flow.collect { onHasSelections.accept(it) } }
+ }
+ } else {
+ MutableStateFlow(true).asStateFlow()
+ }
+ val requestControlFlow =
+ hasSelectionFlow
+ .combine(hasPendingIntentFlow) { hasSelections, hasPendingIntent ->
+ hasSelections && !hasPendingIntent
+ }
+ .distinctUntilChanged()
+ viewModel.request
+ .combine(requestControlFlow) { request, isReady -> request to isReady }
+ // only take ChooserRequest if there are no pending callbacks
+ .filter { it.second }
+ .map { it.first }
+ .distinctUntilChanged(areEquivalent = { old, new -> old === new })
+ .collect { onChooserRequestChanged.accept(it) }
+ }
+ }
+
+ if (interactiveSession()) {
+ activity.lifecycleScope.launch {
+ viewModel.interactiveSessionInteractor.isSessionActive
+ .filter { !it }
+ .collect { activity.finish() }
+ }
+ }
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ Log.i(TAG, "START")
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ Log.i(TAG, "RESUME")
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ Log.i(TAG, "PAUSE")
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ Log.i(TAG, "STOP")
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ Log.i(TAG, "DESTROY")
+ }
+
+ private fun reportErrorsAndFinish(request: Invalid<ChooserRequest>) {
+ request.errors.forEach { it.log(TAG) }
+ activity.finish()
+ }
+
+ private fun initializeActivity(request: Valid<ChooserRequest>) {
+ request.warnings.forEach { it.log(TAG) }
+ activityInitializer.run()
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
deleted file mode 100644
index 5fbf03a0..00000000
--- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
+++ /dev/null
@@ -1,83 +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.intentresolver;
-
-import android.annotation.Nullable;
-import android.content.ComponentName;
-import android.content.Context;
-import android.provider.Settings;
-import android.text.TextUtils;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-/**
- * Helper to look up the components available on this device to handle assorted built-in actions
- * like "Edit" that may be displayed for certain content/preview types. The components are queried
- * when this record is instantiated, and are then immutable for a given instance.
- *
- * Because this describes the app's external execution environment, test methods may prefer to
- * provide explicit values to override the default lookup logic.
- */
-public class ChooserIntegratedDeviceComponents {
- @Nullable
- private final ComponentName mEditSharingComponent;
-
- @Nullable
- private final ComponentName mNearbySharingComponent;
-
- /** Look up the integrated components available on this device. */
- public static ChooserIntegratedDeviceComponents get(
- Context context,
- SecureSettings secureSettings) {
- return new ChooserIntegratedDeviceComponents(
- getEditSharingComponent(context),
- getNearbySharingComponent(context, secureSettings));
- }
-
- @VisibleForTesting
- ChooserIntegratedDeviceComponents(
- ComponentName editSharingComponent, ComponentName nearbySharingComponent) {
- mEditSharingComponent = editSharingComponent;
- mNearbySharingComponent = nearbySharingComponent;
- }
-
- public ComponentName getEditSharingComponent() {
- return mEditSharingComponent;
- }
-
- public ComponentName getNearbySharingComponent() {
- return mNearbySharingComponent;
- }
-
- private static ComponentName getEditSharingComponent(Context context) {
- String editorComponent = context.getApplicationContext().getString(
- R.string.config_systemImageEditor);
- return TextUtils.isEmpty(editorComponent)
- ? null : ComponentName.unflattenFromString(editorComponent);
- }
-
- private static ComponentName getNearbySharingComponent(Context context,
- SecureSettings secureSettings) {
- String nearbyComponent = secureSettings.getString(
- context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT);
- if (TextUtils.isEmpty(nearbyComponent)) {
- nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent);
- }
- return TextUtils.isEmpty(nearbyComponent)
- ? null : ComponentName.unflattenFromString(nearbyComponent);
- }
-}
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index e6d6dbf4..d743f859 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -18,8 +18,8 @@ package com.android.intentresolver;
import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE;
import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER;
+import static com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates;
-import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.prediction.AppTarget;
import android.content.ComponentName;
@@ -38,29 +38,51 @@ import android.os.UserManager;
import android.provider.DeviceConfig;
import android.service.chooser.ChooserTarget;
import android.text.Layout;
+import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
+import androidx.annotation.MainThread;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.chooser.DisplayResolveInfoAzInfoComparator;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.NotSelectableTargetInfo;
import com.android.intentresolver.chooser.SelectableTargetInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.icons.TargetDataLoader;
import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.widget.BadgeTextView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
+import com.google.common.collect.ImmutableList;
+
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.Executor;
import java.util.stream.Collectors;
public class ChooserListAdapter extends ResolverListAdapter {
+
+ /**
+ * Delegate interface for injecting a chooser-specific operation to be performed before handling
+ * a package-change event. This allows the "driver" invoking the package-change to be generic,
+ * with no knowledge specific to the chooser implementation.
+ */
+ public interface PackageChangeCallback {
+ /** Perform any steps necessary before processing the package-change event. */
+ void beforeHandlingPackagesChanged();
+ }
+
private static final String TAG = "ChooserListAdapter";
private static final boolean DEBUG = false;
@@ -78,13 +100,17 @@ public class ChooserListAdapter extends ResolverListAdapter {
/** {@link #getBaseScore} */
public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f;
- private final ChooserRequestParameters mChooserRequest;
+ private final Intent mReferrerFillInIntent;
+
private final int mMaxRankedTargets;
private final EventLog mEventLog;
private final Set<TargetInfo> mRequestedIcons = new HashSet<>();
+ @Nullable
+ private final PackageChangeCallback mPackageChangeCallback;
+
// Reserve spots for incoming direct share targets by adding placeholders
private final TargetInfo mPlaceHolderTargetInfo;
private final TargetDataLoader mTargetDataLoader;
@@ -94,7 +120,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
private final ShortcutSelectionLogic mShortcutSelectionLogic;
// Sorted list of DisplayResolveInfos for the alphabetical app section.
- private List<DisplayResolveInfo> mSortedList = new ArrayList<>();
+ private final List<DisplayResolveInfo> mSortedList = new ArrayList<>();
private final ItemRevealAnimationTracker mAnimationTracker = new ItemRevealAnimationTracker();
@@ -129,6 +155,50 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
};
+ private boolean mAnimateItems = true;
+ private boolean mTargetsEnabled = true;
+ private boolean mDirectTargetsEnabled = true;
+
+ public ChooserListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ Intent referrerFillInIntent,
+ ResolverListCommunicator resolverListCommunicator,
+ PackageManager packageManager,
+ EventLog eventLog,
+ int maxRankedTargets,
+ UserHandle initialIntentsUserSpace,
+ TargetDataLoader targetDataLoader,
+ @Nullable PackageChangeCallback packageChangeCallback) {
+ this(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ referrerFillInIntent,
+ resolverListCommunicator,
+ packageManager,
+ eventLog,
+ maxRankedTargets,
+ initialIntentsUserSpace,
+ targetDataLoader,
+ packageChangeCallback,
+ AsyncTask.SERIAL_EXECUTOR,
+ context.getMainExecutor()
+ );
+ }
+
+ @VisibleForTesting
public ChooserListAdapter(
Context context,
List<Intent> payloadIntents,
@@ -138,13 +208,16 @@ public class ChooserListAdapter extends ResolverListAdapter {
ResolverListController resolverListController,
UserHandle userHandle,
Intent targetIntent,
+ Intent referrerFillInIntent,
ResolverListCommunicator resolverListCommunicator,
PackageManager packageManager,
EventLog eventLog,
- ChooserRequestParameters chooserRequest,
int maxRankedTargets,
UserHandle initialIntentsUserSpace,
- TargetDataLoader targetDataLoader) {
+ TargetDataLoader targetDataLoader,
+ @Nullable PackageChangeCallback packageChangeCallback,
+ Executor bgExecutor,
+ Executor mainExecutor) {
// Don't send the initial intents through the shared ResolverActivity path,
// we want to separate them into a different section.
super(
@@ -158,13 +231,16 @@ public class ChooserListAdapter extends ResolverListAdapter {
targetIntent,
resolverListCommunicator,
initialIntentsUserSpace,
- targetDataLoader);
+ targetDataLoader,
+ bgExecutor,
+ mainExecutor);
- mChooserRequest = chooserRequest;
mMaxRankedTargets = maxRankedTargets;
+ mReferrerFillInIntent = referrerFillInIntent;
mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context);
mTargetDataLoader = targetDataLoader;
+ mPackageChangeCallback = packageChangeCallback;
createPlaceHolders();
mEventLog = eventLog;
mShortcutSelectionLogic = new ShortcutSelectionLogic(
@@ -227,17 +303,45 @@ public class ChooserListAdapter extends ResolverListAdapter {
ri.icon = 0;
}
ri.userHandle = initialIntentsUserSpace;
- // TODO: remove DisplayResolveInfo dependency on presentation getter
- DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo(
- ii, ri, ii, mTargetDataLoader.createPresentationGetter(ri));
+ DisplayResolveInfo displayResolveInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(ii, ri, ii);
mCallerTargets.add(displayResolveInfo);
if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break;
}
}
}
+ /**
+ * Set the enabled state for all targets.
+ */
+ public void setTargetsEnabled(boolean isEnabled) {
+ if (mTargetsEnabled != isEnabled) {
+ mTargetsEnabled = isEnabled;
+ notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * Set the enabled state for direct targets.
+ */
+ public void setDirectTargetsEnabled(boolean isEnabled) {
+ if (mDirectTargetsEnabled != isEnabled) {
+ mDirectTargetsEnabled = isEnabled;
+ if (!mServiceTargets.isEmpty() && !isDirectTargetRowEmptyState()) {
+ notifyDataSetChanged();
+ }
+ }
+ }
+
+ public void setAnimateItems(boolean animateItems) {
+ mAnimateItems = animateItems;
+ }
+
@Override
public void handlePackagesChanged() {
+ if (mPackageChangeCallback != null) {
+ mPackageChangeCallback.beforeHandlingPackagesChanged();
+ }
if (DEBUG) {
Log.d(TAG, "clearing queryTargets on package change");
}
@@ -247,7 +351,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
@Override
- protected boolean rebuildList(boolean doPostProcessing) {
+ public boolean rebuildList(boolean doPostProcessing) {
mAnimationTracker.reset();
mSortedList.clear();
boolean result = super.rebuildList(doPostProcessing);
@@ -264,105 +368,165 @@ public class ChooserListAdapter extends ResolverListAdapter {
@Override
View onCreateView(ViewGroup parent) {
- return mInflater.inflate(R.layout.resolve_grid_item, parent, false);
+ int layout = targetHoverAndKeyboardFocusStates()
+ ? R.layout.chooser_grid_item_hover
+ : R.layout.chooser_grid_item;
+ return mInflater.inflate(layout, parent, false);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ notifyDataSetChanged();
}
@VisibleForTesting
@Override
public void onBindView(View view, TargetInfo info, int position) {
+ final boolean isEnabled = !isDestroyed() && mTargetsEnabled;
+ view.setEnabled(isEnabled);
final ViewHolder holder = (ViewHolder) view.getTag();
+ resetViewHolder(holder);
+ // Always remove the spacing listener, attach as needed to direct share targets below.
+ holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener);
+
if (info == null) {
holder.icon.setImageDrawable(loadIconPlaceholder());
return;
}
- holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo());
- mAnimationTracker.animateLabel(holder.text, info);
- if (holder.text2.getVisibility() == View.VISIBLE) {
- mAnimationTracker.animateLabel(holder.text2, info);
+ final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), "");
+ final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), "");
+ holder.bindLabel(displayLabel, extendedInfo);
+ if (mAnimateItems && !TextUtils.isEmpty(displayLabel)) {
+ mAnimationTracker.animateLabel(holder.text, info);
}
- holder.bindIcon(info);
- if (info.getDisplayIconHolder().getDisplayIcon() != null) {
- mAnimationTracker.animateIcon(holder.icon, info);
- } else {
- holder.icon.clearAnimation();
+ if (mAnimateItems
+ && !TextUtils.isEmpty(extendedInfo)
+ && holder.text2.getVisibility() == View.VISIBLE) {
+ mAnimationTracker.animateLabel(holder.text2, info);
}
if (info.isSelectableTargetInfo()) {
+ view.setEnabled(isEnabled && mDirectTargetsEnabled);
// direct share targets should append the application name for a better readout
DisplayResolveInfo rInfo = info.getDisplayResolveInfo();
- CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : "";
- CharSequence extendedInfo = info.getExtendedInfo();
- String contentDescription = String.join(" ", info.getDisplayLabel(),
- extendedInfo != null ? extendedInfo : "", appName);
- holder.updateContentDescription(contentDescription);
+ CharSequence appName =
+ Objects.requireNonNullElse(rInfo == null ? null : rInfo.getDisplayLabel(), "");
+ String contentDescription =
+ String.join(" ", info.getDisplayLabel(), extendedInfo, appName);
+ if (info.isPinned()) {
+ contentDescription = String.join(
+ ". ",
+ contentDescription,
+ mContext.getResources().getString(R.string.pinned));
+ }
+ updateContentDescription(holder, contentDescription);
if (!info.hasDisplayIcon()) {
loadDirectShareIcon((SelectableTargetInfo) info);
}
} else if (info.isDisplayResolveInfo()) {
+ if (info.isPinned()) {
+ updateContentDescription(
+ holder,
+ String.join(
+ ". ",
+ info.getDisplayLabel(),
+ mContext.getResources().getString(R.string.pinned)));
+ }
DisplayResolveInfo dri = (DisplayResolveInfo) info;
if (!dri.hasDisplayIcon()) {
loadIcon(dri);
}
+ if (!dri.hasDisplayLabel()) {
+ loadLabel(dri);
+ }
}
- // If target is loading, show a special placeholder shape in the label, make unclickable
- if (info.isPlaceHolderTargetInfo()) {
- final int maxWidth = mContext.getResources().getDimensionPixelSize(
- R.dimen.chooser_direct_share_label_placeholder_max_width);
- holder.text.setMaxWidth(maxWidth);
- holder.text.setBackground(mContext.getResources().getDrawable(
- R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme()));
- // Prevent rippling by removing background containing ripple
- holder.itemView.setBackground(null);
- } else {
- holder.text.setMaxWidth(Integer.MAX_VALUE);
- holder.text.setBackground(null);
- holder.itemView.setBackground(holder.defaultItemViewBackground);
+ holder.bindIcon(info, mTargetsEnabled);
+ if (mAnimateItems && info.hasDisplayIcon()) {
+ mAnimationTracker.animateIcon(holder.icon, info);
}
- // Always remove the spacing listener, attach as needed to direct share targets below.
- holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener);
+ if (info.isPlaceHolderTargetInfo()) {
+ bindPlaceholder(holder);
+ }
if (info.isMultiDisplayResolveInfo()) {
// If the target is grouped show an indicator
- Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background);
- holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0);
- holder.text.setBackground(bkg);
+ bindGroupIndicator(
+ holder,
+ mContext.getDrawable(R.drawable.chooser_group_background));
} else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD
|| getPositionTargetType(position) == TARGET_SERVICE)) {
// If the appShare or directShare target is pinned and in the suggested row show a
// pinned indicator
- Drawable bkg = mContext.getDrawable(R.drawable.chooser_pinned_background);
- holder.text.setPaddingRelative(bkg.getIntrinsicWidth() /* start */, 0, 0, 0);
- holder.text.setBackground(bkg);
+ bindPinnedIndicator(holder, mContext.getDrawable(R.drawable.chooser_pinned_background));
holder.text.addOnLayoutChangeListener(mPinTextSpacingListener);
- } else {
- holder.text.setBackground(null);
- holder.text.setPaddingRelative(0, 0, 0, 0);
}
}
+ private void resetViewHolder(ViewHolder holder) {
+ holder.reset();
+ holder.itemView.setBackground(holder.defaultItemViewBackground);
+
+ ((BadgeTextView) holder.text).setBadgeDrawable(null);
+ holder.text.setBackground(null);
+ holder.text.setPaddingRelative(0, 0, 0, 0);
+ }
+
+ private void updateContentDescription(ViewHolder holder, String description) {
+ holder.itemView.setContentDescription(description);
+ }
+
+ private void bindPlaceholder(ViewHolder holder) {
+ holder.itemView.setBackground(null);
+ }
+
+ private void bindGroupIndicator(ViewHolder holder, Drawable indicator) {
+ ((BadgeTextView) holder.text).setBadgeDrawable(indicator);
+ }
+
+ private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) {
+ holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0);
+ holder.text.setBackground(indicator);
+ }
+
private void loadDirectShareIcon(SelectableTargetInfo info) {
if (mRequestedIcons.add(info)) {
- mTargetDataLoader.loadDirectShareIcon(
+ Drawable icon = mTargetDataLoader.getOrLoadDirectShareIcon(
info,
getUserHandle(),
- (drawable) -> onDirectShareIconLoaded(info, drawable));
+ (drawable) -> onDirectShareIconLoaded(info, drawable, true));
+ if (icon != null) {
+ onDirectShareIconLoaded(info, icon, false);
+ }
}
}
- private void onDirectShareIconLoaded(SelectableTargetInfo mTargetInfo, Drawable icon) {
+ private void onDirectShareIconLoaded(
+ SelectableTargetInfo mTargetInfo, @Nullable Drawable icon, boolean notify) {
if (icon != null && !mTargetInfo.hasDisplayIcon()) {
mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon);
- notifyDataSetChanged();
+ if (notify) {
+ notifyDataSetChanged();
+ }
}
}
- void updateAlphabeticalList() {
- // TODO: this procedure seems like it should be relatively lightweight. Why does it need to
- // run in an `AsyncTask`?
+ /**
+ * Group application targets
+ */
+ public void updateAlphabeticalList(Runnable onCompleted) {
+ final DisplayResolveInfoAzInfoComparator
+ comparator = new DisplayResolveInfoAzInfoComparator(mContext);
+ ImmutableList<DisplayResolveInfo> displayList = getTargetsInCurrentDisplayList();
+ final List<DisplayResolveInfo> allTargets =
+ new ArrayList<>(displayList.size() + mCallerTargets.size());
+ allTargets.addAll(displayList);
+ allTargets.addAll(mCallerTargets);
+
new AsyncTask<Void, Void, List<DisplayResolveInfo>>() {
@Override
protected List<DisplayResolveInfo> doInBackground(Void... voids) {
@@ -375,31 +539,57 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
private List<DisplayResolveInfo> updateList() {
- List<DisplayResolveInfo> allTargets = new ArrayList<>();
- allTargets.addAll(getTargetsInCurrentDisplayList());
- allTargets.addAll(mCallerTargets);
+ loadMissingLabels(allTargets);
// Consolidate multiple targets from same app.
return allTargets
.stream()
+ .map(appTarget -> {
+ if (targetHoverAndKeyboardFocusStates()) {
+ // Icon drawables are effectively cached per target info.
+ // Without cloning target infos, the same target info could be used
+ // for two different positions in the grid: once in the ranked
+ // targets row (from ResolverListAdapter#mDisplayList or
+ // #mCallerTargets, see #getItem()) and again in the all-app-target
+ // grid (copied from #mDisplayList and #mCallerTargets to
+ // #mSortedList).
+ // Using the same drawable for two list items would result in visual
+ // effects being applied to both simultaneously.
+ DisplayResolveInfo copy = appTarget.copy();
+ copy.getDisplayIconHolder().setDisplayIcon(null);
+ return copy;
+ } else {
+ return appTarget;
+ }
+ })
.collect(Collectors.groupingBy(target ->
target.getResolvedComponentName().getPackageName()
- + "#" + target.getDisplayLabel()
- + '#' + target.getResolveInfo().userHandle.getIdentifier()
+ + "#" + target.getDisplayLabel()
+ + '#' + target.getResolveInfo().userHandle.getIdentifier()
))
.values()
.stream()
.map(appTargets ->
(appTargets.size() == 1)
- ? appTargets.get(0)
- : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets))
- .sorted(new ChooserActivity.AzInfoComparator(mContext))
+ ? appTargets.get(0)
+ : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+ appTargets))
+ .sorted(comparator)
.collect(Collectors.toList());
}
+
@Override
protected void onPostExecute(List<DisplayResolveInfo> newList) {
- mSortedList = newList;
+ mSortedList.clear();
+ mSortedList.addAll(newList);
notifyDataSetChanged();
+ onCompleted.run();
+ }
+
+ private void loadMissingLabels(List<DisplayResolveInfo> targets) {
+ for (DisplayResolveInfo target: targets) {
+ mTargetDataLoader.getOrLoadLabel(target);
+ }
}
}.execute();
}
@@ -438,8 +628,14 @@ public class ChooserListAdapter extends ResolverListAdapter {
return count;
}
+ private static boolean hasSendAction(Intent intent) {
+ String action = intent.getAction();
+ return Intent.ACTION_SEND.equals(action)
+ || Intent.ACTION_SEND_MULTIPLE.equals(action);
+ }
+
public int getServiceTargetCount() {
- if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) {
+ if (hasSendAction(getTargetIntent()) && !ActivityManager.isLowRamDeviceStatic()) {
return Math.min(mServiceTargets.size(), mMaxRankedTargets);
}
@@ -553,7 +749,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) {
// Checks if this info is already listed in callerTargets.
for (TargetInfo existingInfo : mCallerTargets) {
- if (mResolverListCommunicator.resolveInfoMatch(
+ if (ResolveInfoHelpers.resolveInfoMatch(
dri.getResolveInfo(), existingInfo.getResolveInfo())) {
return false;
}
@@ -577,11 +773,11 @@ public class ChooserListAdapter extends ResolverListAdapter {
public void addServiceResults(
@Nullable DisplayResolveInfo origTarget,
List<ChooserTarget> targets,
- @ChooserActivity.ShareTargetType int targetType,
+ int targetType,
Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos,
Map<ChooserTarget, AppTarget> directShareToAppTargets) {
// Avoid inserting any potentially late results.
- if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) {
+ if (isDirectTargetRowEmptyState()) {
return;
}
boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER
@@ -594,8 +790,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
directShareToShortcutInfos,
directShareToAppTargets,
mContext.createContextAsUser(getUserHandle(), 0),
- mChooserRequest.getTargetIntent(),
- mChooserRequest.getReferrerFillInIntent(),
+ getTargetIntent(),
+ mReferrerFillInIntent,
mMaxRankedTargets,
mServiceTargets);
if (isUpdated) {
@@ -604,6 +800,29 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
/**
+ * Copy direct targets from another ChooserListAdapter instance
+ */
+ public void copyDirectTargetsFrom(ChooserListAdapter adapter) {
+ if (adapter.isDirectTargetRowEmptyState()) {
+ return;
+ }
+
+ mServiceTargets.clear();
+ mServiceTargets.addAll(adapter.mServiceTargets);
+ }
+
+ /**
+ * Reset direct targets
+ */
+ public void resetDirectTargets() {
+ createPlaceHolders();
+ }
+
+ private boolean isDirectTargetRowEmptyState() {
+ return (mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo();
+ }
+
+ /**
* Use the scoring system along with artificial boosts to create up to 4 distinct buckets:
* <ol>
* <li>App-supplied targets
@@ -614,7 +833,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
*/
public float getBaseScore(
DisplayResolveInfo target,
- @ChooserActivity.ShareTargetType int targetType) {
+ int targetType) {
if (target == null) {
return CALLER_TARGET_SCORE_BOOST;
}
@@ -644,29 +863,20 @@ public class ChooserListAdapter extends ResolverListAdapter {
* in the head of input list and fill the tail with other elements in undetermined order.
*/
@Override
- AsyncTask<List<ResolvedComponentInfo>,
- Void,
- List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) {
- return new AsyncTask<List<ResolvedComponentInfo>,
- Void,
- List<ResolvedComponentInfo>>() {
- @Override
- protected List<ResolvedComponentInfo> doInBackground(
- List<ResolvedComponentInfo>... params) {
- Trace.beginSection("ChooserListAdapter#SortingTask");
- mResolverListController.topK(params[0], mMaxRankedTargets);
- Trace.endSection();
- return params[0];
- }
- @Override
- protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) {
- processSortedList(sortedComponents, doPostProcessing);
- if (doPostProcessing) {
- mResolverListCommunicator.updateProfileViewButton();
- notifyDataSetChanged();
- }
- }
- };
+ @WorkerThread
+ protected void sortComponents(List<ResolvedComponentInfo> components) {
+ Trace.beginSection("ChooserListAdapter#SortingTask");
+ mResolverListController.topK(components, mMaxRankedTargets);
+ Trace.endSection();
}
+ @Override
+ @MainThread
+ protected void onComponentsSorted(
+ @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) {
+ processSortedList(sortedComponents, doPostProcessing);
+ if (doPostProcessing) {
+ notifyDataSetChanged();
+ }
+ }
}
diff --git a/java/src/com/android/intentresolver/ChooserListController.java b/java/src/com/android/intentresolver/ChooserListController.java
new file mode 100644
index 00000000..48aa8be1
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserListController.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 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.intentresolver;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.UserHandle;
+
+import com.android.intentresolver.model.AbstractResolverComparator;
+
+import java.util.List;
+
+public class ChooserListController extends ResolverListController {
+ private final List<ComponentName> mFilteredComponents;
+ private final SharedPreferences mPinnedComponents;
+
+ public ChooserListController(
+ Context context,
+ PackageManager pm,
+ Intent targetIntent,
+ String referrerPackageName,
+ int launchedFromUid,
+ AbstractResolverComparator resolverComparator,
+ UserHandle queryIntentsAsUser,
+ List<ComponentName> filteredComponents,
+ SharedPreferences pinnedComponents) {
+ super(
+ context,
+ pm,
+ targetIntent,
+ referrerPackageName,
+ launchedFromUid,
+ resolverComparator,
+ queryIntentsAsUser);
+ mFilteredComponents = filteredComponents;
+ mPinnedComponents = pinnedComponents;
+ }
+
+ @Override
+ public boolean isComponentFiltered(ComponentName name) {
+ return mFilteredComponents.contains(name);
+ }
+
+ @Override
+ public boolean isComponentPinned(ComponentName name) {
+ return mPinnedComponents.getBoolean(name.flattenToString(), false);
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
index 250b6827..d6688d90 100644
--- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
+++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
@@ -16,20 +16,20 @@
package com.android.intentresolver;
-import android.annotation.NonNull;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
+import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
-class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate {
+public class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate {
private final Rect mTempRect = new Rect();
private final int[] mConsumed = new int[2];
- ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) {
+ public ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) {
super(recyclerView);
}
diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java
index 2ebe48a6..5c828a8e 100644
--- a/java/src/com/android/intentresolver/ChooserRefinementManager.java
+++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java
@@ -16,8 +16,6 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
-import android.annotation.UiThread;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
@@ -28,22 +26,29 @@ import android.os.Parcel;
import android.os.ResultReceiver;
import android.util.Log;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.android.intentresolver.chooser.TargetInfo;
+import dagger.hilt.android.lifecycle.HiltViewModel;
+
import java.util.List;
import java.util.function.Consumer;
+import javax.inject.Inject;
+
/**
* Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement
* activity" that will be invoked when a target is selected, allowing the calling app to add
- * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to
+ * additional extras and other refinements (subject to {@link Intent#filterEquals}), e.g., to
* convert the format of the payload, or lazy-download some data that was deferred in the original
* call).
*/
+@HiltViewModel
@UiThread
public final class ChooserRefinementManager extends ViewModel {
private static final String TAG = "ChooserRefinement";
@@ -54,22 +59,58 @@ public final class ChooserRefinementManager extends ViewModel {
private boolean mConfigurationChangeInProgress = false;
/**
+ * The types of selections that may be sent to refinement.
+ *
+ * The refinement flow results in a refined intent, but the interpretation of that intent
+ * depends on the type of selection that prompted the refinement.
+ */
+ public enum RefinementType {
+ TARGET_INFO, // A normal (`TargetInfo`) target.
+
+ // System actions derived from the refined intent (from `ChooserActionFactory`).
+ COPY_ACTION,
+ EDIT_ACTION
+ }
+
+ /**
* A token for the completion of a refinement process that can be consumed exactly once.
*/
public static class RefinementCompletion {
private TargetInfo mTargetInfo;
private boolean mConsumed;
+ private final RefinementType mType;
- RefinementCompletion(TargetInfo targetInfo) {
- mTargetInfo = targetInfo;
+ @Nullable
+ private final TargetInfo mOriginalTargetInfo;
+
+ @Nullable
+ private final Intent mRefinedIntent;
+
+ RefinementCompletion(
+ @Nullable RefinementType type,
+ @Nullable TargetInfo originalTargetInfo,
+ @Nullable Intent refinedIntent) {
+ mType = type;
+ mOriginalTargetInfo = originalTargetInfo;
+ mRefinedIntent = refinedIntent;
+ }
+
+ public RefinementType getType() {
+ return mType;
+ }
+
+ @Nullable
+ public TargetInfo getOriginalTargetInfo() {
+ return mOriginalTargetInfo;
}
/**
* @return The output of the completed refinement process. Null if the process was aborted
* or failed.
*/
- public TargetInfo getTargetInfo() {
- return mTargetInfo;
+ @Nullable
+ public Intent getRefinedIntent() {
+ return mRefinedIntent;
}
/**
@@ -88,6 +129,9 @@ public final class ChooserRefinementManager extends ViewModel {
private MutableLiveData<RefinementCompletion> mRefinementCompletion = new MutableLiveData<>();
+ @Inject
+ public ChooserRefinementManager() {}
+
public LiveData<RefinementCompletion> getRefinementCompletion() {
return mRefinementCompletion;
}
@@ -97,14 +141,11 @@ public final class ChooserRefinementManager extends ViewModel {
* @return true if the selection should wait for a now-started refinement flow, or false if it
* can proceed by the default (non-refinement) logic.
*/
- public boolean maybeHandleSelection(TargetInfo selectedTarget,
- IntentSender refinementIntentSender, Application application, Handler mainHandler) {
- if (refinementIntentSender == null) {
- return false;
- }
- if (selectedTarget.getAllSourceIntents().isEmpty()) {
- return false;
- }
+ public boolean maybeHandleSelection(
+ TargetInfo selectedTarget,
+ IntentSender refinementIntentSender,
+ Application application,
+ Handler mainHandler) {
if (selectedTarget.isSuspended()) {
// We expect all launches to fail for this target, so don't make the user go through the
// refinement flow first. Besides, the default (non-refinement) handling displays a
@@ -113,27 +154,57 @@ public final class ChooserRefinementManager extends ViewModel {
return false;
}
+ return maybeHandleSelection(
+ RefinementType.TARGET_INFO,
+ selectedTarget.getAllSourceIntents(),
+ selectedTarget,
+ refinementIntentSender,
+ application,
+ mainHandler);
+ }
+
+ /**
+ * Delegate the user's selection of targets (with one or more matching {@code sourceIntents} to
+ * the refinement flow, if possible.
+ * @return true if the selection should wait for a now-started refinement flow, or false if it
+ * can proceed by the default (non-refinement) logic.
+ */
+ public boolean maybeHandleSelection(
+ RefinementType refinementType,
+ List<Intent> sourceIntents,
+ @Nullable TargetInfo originalTargetInfo,
+ IntentSender refinementIntentSender,
+ Application application,
+ Handler mainHandler) {
+ // Our requests have a non-null `originalTargetInfo` in exactly the
+ // cases when `refinementType == TARGET_INFO`.
+ assert ((originalTargetInfo == null) == (refinementType == RefinementType.TARGET_INFO));
+
+ if (refinementIntentSender == null) {
+ return false;
+ }
+ if (sourceIntents.isEmpty()) {
+ return false;
+ }
+
destroy(); // Terminate any prior sessions.
mRefinementResultReceiver = new RefinementResultReceiver(
+ refinementType,
refinedIntent -> {
destroy();
-
- TargetInfo refinedTarget =
- selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent);
- if (refinedTarget != null) {
- mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget));
- } else {
- Log.e(TAG, "Failed to apply refinement to any matching source intent");
- mRefinementCompletion.setValue(new RefinementCompletion(null));
- }
+ mRefinementCompletion.setValue(
+ new RefinementCompletion(
+ refinementType, originalTargetInfo, refinedIntent));
},
() -> {
destroy();
- mRefinementCompletion.setValue(new RefinementCompletion(null));
+ mRefinementCompletion.setValue(
+ new RefinementCompletion(
+ refinementType, originalTargetInfo, null));
},
mainHandler);
- Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget);
+ Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, sourceIntents);
try {
refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null);
return true;
@@ -159,7 +230,7 @@ public final class ChooserRefinementManager extends ViewModel {
// into a valid Chooser session, so we'll treat it as a cancellation instead.
Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting");
destroy();
- mRefinementCompletion.setValue(new RefinementCompletion(null));
+ mRefinementCompletion.setValue(new RefinementCompletion(null, null, null));
}
}
}
@@ -179,9 +250,8 @@ public final class ChooserRefinementManager extends ViewModel {
}
private static Intent makeRefinementRequest(
- RefinementResultReceiver resultReceiver, TargetInfo originalTarget) {
+ RefinementResultReceiver resultReceiver, List<Intent> sourceIntents) {
final Intent fillIn = new Intent();
- final List<Intent> sourceIntents = originalTarget.getAllSourceIntents();
fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
final int sourceIntentCount = sourceIntents.size();
if (sourceIntentCount > 1) {
@@ -196,16 +266,19 @@ public final class ChooserRefinementManager extends ViewModel {
}
private static class RefinementResultReceiver extends ResultReceiver {
+ private final RefinementType mType;
private final Consumer<Intent> mOnSelectionRefined;
private final Runnable mOnRefinementCancelled;
private boolean mDestroyed;
RefinementResultReceiver(
+ RefinementType type,
Consumer<Intent> onSelectionRefined,
Runnable onRefinementCancelled,
Handler handler) {
super(handler);
+ mType = type;
mOnSelectionRefined = onSelectionRefined;
mOnRefinementCancelled = onRefinementCancelled;
}
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
deleted file mode 100644
index 5157986b..00000000
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ /dev/null
@@ -1,483 +0,0 @@
-/*
- * Copyright (C) 2008 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.intentresolver;
-
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.content.ComponentName;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.IntentSender;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Parcelable;
-import android.os.PatternMatcher;
-import android.service.chooser.ChooserAction;
-import android.service.chooser.ChooserTarget;
-import android.text.TextUtils;
-import android.util.Log;
-import android.util.Pair;
-
-import com.android.intentresolver.flags.FeatureFlagRepository;
-import com.android.intentresolver.util.UriFilters;
-
-import com.google.common.collect.ImmutableList;
-
-import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.stream.Collector;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched
- * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars.
- *
- * TODO: field nullability in this class reflects legacy use, and typically would indicate that the
- * client's intent didn't provide the respective data. In some cases we may be able to provide
- * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the
- * client code could instead handle empty collections equally well.
- *
- * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to
- * it internally) differ from the legacy model because they're computed directly from the initial
- * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved
- * through methods on the base class. The base always seems to return them exactly as they were
- * provided, so this should be safe -- and clients can reasonably switch to retrieving through these
- * parameters instead. For now, the other convention is still used in some places. Ideally we'd like
- * to normalize on a single source of truth, but we'll have to clean up the delegation up to the
- * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?).
- */
-public class ChooserRequestParameters {
- private static final String TAG = "ChooserActivity";
-
- private static final int LAUNCH_FLAGS_FOR_SEND_ACTION =
- Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
- private static final int MAX_CHOOSER_ACTIONS = 5;
-
- private final Intent mTarget;
- private final String mReferrerPackageName;
- private final Pair<CharSequence, Integer> mTitleSpec;
- private final Intent mReferrerFillInIntent;
- private final ImmutableList<ComponentName> mFilteredComponentNames;
- private final ImmutableList<ChooserTarget> mCallerChooserTargets;
- private final @NonNull ImmutableList<ChooserAction> mChooserActions;
- private final ChooserAction mModifyShareAction;
- private final boolean mRetainInOnStop;
-
- @Nullable
- private final ImmutableList<Intent> mAdditionalTargets;
-
- @Nullable
- private final Bundle mReplacementExtras;
-
- @Nullable
- private final ImmutableList<Intent> mInitialIntents;
-
- @Nullable
- private final IntentSender mChosenComponentSender;
-
- @Nullable
- private final IntentSender mRefinementIntentSender;
-
- @Nullable
- private final String mSharedText;
-
- @Nullable
- private final IntentFilter mTargetIntentFilter;
-
- public ChooserRequestParameters(
- final Intent clientIntent,
- String referrerPackageName,
- final Uri referrer,
- FeatureFlagRepository featureFlags) {
- final Intent requestedTarget = parseTargetIntentExtra(
- clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
- mTarget = intentWithModifiedLaunchFlags(requestedTarget);
-
- mReferrerPackageName = referrerPackageName;
-
- mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- clientIntent, Intent.EXTRA_ALTERNATE_INTENTS);
-
- mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS);
-
- mTitleSpec = makeTitleSpec(
- clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE),
- isSendAction(mTarget.getAction()));
-
- mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- clientIntent, Intent.EXTRA_INITIAL_INTENTS);
-
- mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer);
-
- mChosenComponentSender = clientIntent.getParcelableExtra(
- Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER);
- mRefinementIntentSender = clientIntent.getParcelableExtra(
- Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
-
- ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra(
- Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class);
- mFilteredComponentNames = filteredComponents != null
- ? ImmutableList.copyOf(filteredComponents)
- : ImmutableList.of();
-
- mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent);
-
- mRetainInOnStop = clientIntent.getBooleanExtra(
- ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false);
-
- mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT);
-
- mTargetIntentFilter = getTargetIntentFilter(mTarget);
-
- mChooserActions = getChooserActions(clientIntent);
- mModifyShareAction = getModifyShareAction(clientIntent);
- }
-
- public Intent getTargetIntent() {
- return mTarget;
- }
-
- @Nullable
- public String getTargetAction() {
- return getTargetIntent().getAction();
- }
-
- public boolean isSendActionTarget() {
- return isSendAction(getTargetAction());
- }
-
- @Nullable
- public String getTargetType() {
- return getTargetIntent().getType();
- }
-
- public String getReferrerPackageName() {
- return mReferrerPackageName;
- }
-
- @Nullable
- public CharSequence getTitle() {
- return mTitleSpec.first;
- }
-
- public int getDefaultTitleResource() {
- return mTitleSpec.second;
- }
-
- public Intent getReferrerFillInIntent() {
- return mReferrerFillInIntent;
- }
-
- public ImmutableList<ComponentName> getFilteredComponentNames() {
- return mFilteredComponentNames;
- }
-
- public ImmutableList<ChooserTarget> getCallerChooserTargets() {
- return mCallerChooserTargets;
- }
-
- @NonNull
- public ImmutableList<ChooserAction> getChooserActions() {
- return mChooserActions;
- }
-
- @Nullable
- public ChooserAction getModifyShareAction() {
- return mModifyShareAction;
- }
-
- /**
- * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
- */
- public boolean shouldRetainInOnStop() {
- return mRetainInOnStop;
- }
-
- /**
- * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link mAdditionalTargets} directly is simpler and safer.
- */
- @Nullable
- public Intent[] getAdditionalTargets() {
- return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]);
- }
-
- @Nullable
- public Bundle getReplacementExtras() {
- return mReplacementExtras;
- }
-
- /**
- * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link mInitialIntents} directly is simpler and safer.
- */
- @Nullable
- public Intent[] getInitialIntents() {
- return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]);
- }
-
- @Nullable
- public IntentSender getChosenComponentSender() {
- return mChosenComponentSender;
- }
-
- @Nullable
- public IntentSender getRefinementIntentSender() {
- return mRefinementIntentSender;
- }
-
- @Nullable
- public String getSharedText() {
- return mSharedText;
- }
-
- @Nullable
- public IntentFilter getTargetIntentFilter() {
- return mTargetIntentFilter;
- }
-
- private static boolean isSendAction(@Nullable String action) {
- return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
- }
-
- private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) {
- if (targetParcelable instanceof Uri) {
- try {
- targetParcelable = Intent.parseUri(targetParcelable.toString(),
- Intent.URI_INTENT_SCHEME);
- } catch (URISyntaxException ex) {
- throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex);
- }
- }
-
- if (!(targetParcelable instanceof Intent)) {
- throw new IllegalArgumentException(
- "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable);
- }
-
- return ((Intent) targetParcelable);
- }
-
- private static Intent intentWithModifiedLaunchFlags(Intent intent) {
- if (isSendAction(intent.getAction())) {
- intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION);
- }
- return intent;
- }
-
- /**
- * Build a pair of values specifying the title to use from the client request. The first
- * ({@link CharSequence}) value is the client-specified title, if there was one and their
- * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is
- * the resource ID of a default title string; this is nonzero only if the first value is null.
- *
- * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or
- * create a real type (not {@link Pair}) to express the semantics described in this comment.
- */
- private static Pair<CharSequence, Integer> makeTitleSpec(
- @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) {
- if (hasSendActionTarget && (requestedTitle != null)) {
- // Do not allow the title to be changed when sharing content
- Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a"
- + " preview title by using EXTRA_TITLE property of the wrapped"
- + " EXTRA_INTENT.");
- requestedTitle = null;
- }
-
- int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0;
-
- return Pair.create(requestedTitle, defaultTitleRes);
- }
-
- private static ImmutableList<ChooserTarget> parseCallerTargetsFromClientIntent(
- Intent clientIntent) {
- return
- streamParcelableArrayExtra(
- clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true)
- .collect(toImmutableList());
- }
-
- @NonNull
- private static ImmutableList<ChooserAction> getChooserActions(Intent intent) {
- return streamParcelableArrayExtra(
- intent,
- Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
- ChooserAction.class,
- true,
- true)
- .filter(UriFilters::hasValidIcon)
- .limit(MAX_CHOOSER_ACTIONS)
- .collect(toImmutableList());
- }
-
- @Nullable
- private static ChooserAction getModifyShareAction(Intent intent) {
- try {
- return intent.getParcelableExtra(
- Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
- ChooserAction.class);
- } catch (Throwable t) {
- Log.w(
- TAG,
- "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument",
- t);
- return null;
- }
- }
-
- private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() {
- return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf);
- }
-
- @Nullable
- private static ImmutableList<Intent> intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- Intent clientIntent, String extra) {
- Stream<Intent> intents =
- streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false);
- if (intents == null) {
- return null;
- }
- return intents
- .map(ChooserRequestParameters::intentWithModifiedLaunchFlags)
- .collect(toImmutableList());
- }
-
- /**
- * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent}
- * as the optional parcelable array extra with key {@code extra}. The stream elements, if any,
- * are all of the type specified by {@code clazz}.
- *
- * @param intent The intent that may contain the optional extras.
- * @param extra The extras key to identify the parcelable array.
- * @param clazz A class that is assignable from any elements in the result stream.
- * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have
- * the required type. If false, throw an {@link IllegalArgumentException} if the extra is
- * non-null but can't be assigned to variables of type {@code T}.
- * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't
- * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true).
- * If false, return null in these cases, and only return an empty stream if the intent
- * explicitly provided an empty array for the specified extra.
- */
- @Nullable
- private static <T extends Parcelable> Stream<T> streamParcelableArrayExtra(
- final Intent intent,
- String extra,
- @NonNull Class<T> clazz,
- boolean warnOnTypeError,
- boolean streamEmptyIfNull) {
- T[] result = null;
-
- try {
- result = getParcelableArrayExtraIfPresent(intent, extra, clazz);
- } catch (IllegalArgumentException e) {
- if (warnOnTypeError) {
- Log.w(TAG, "Ignoring client-requested " + extra, e);
- } else {
- throw e;
- }
- }
-
- if (result != null) {
- return Arrays.stream(result);
- } else if (streamEmptyIfNull) {
- return Stream.empty();
- } else {
- return null;
- }
- }
-
- /**
- * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]}
- * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't
- * present in the {@code intent}, return null.
- */
- @Nullable
- private static <T extends Parcelable> T[] getParcelableArrayExtraIfPresent(
- final Intent intent, String extra, @NonNull Class<T> clazz) throws
- IllegalArgumentException {
- if (!intent.hasExtra(extra)) {
- return null;
- }
-
- T[] castResult = intent.getParcelableArrayExtra(extra, clazz);
- if (castResult == null) {
- Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra);
- if (actualExtrasArray != null) {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s[]: %s",
- extra,
- clazz.getSimpleName(),
- Arrays.toString(actualExtrasArray)));
- } else if (intent.getParcelableExtra(extra) != null) {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s[] (or any array type): %s",
- extra,
- clazz.getSimpleName(),
- intent.getParcelableExtra(extra)));
- } else {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s (or any Parcelable type): %s",
- extra,
- clazz.getSimpleName(),
- intent.getExtras().get(extra)));
- }
- }
-
- return castResult;
- }
-
- private static IntentFilter getTargetIntentFilter(final Intent intent) {
- try {
- String dataString = intent.getDataString();
- if (intent.getType() == null) {
- if (!TextUtils.isEmpty(dataString)) {
- return new IntentFilter(intent.getAction(), dataString);
- }
- Log.e(TAG, "Failed to get target intent filter: intent data and type are null");
- return null;
- }
- IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType());
- List<Uri> contentUris = new ArrayList<>();
- if (Intent.ACTION_SEND.equals(intent.getAction())) {
- Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (uri != null) {
- contentUris.add(uri);
- }
- } else {
- List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (uris != null) {
- contentUris.addAll(uris);
- }
- }
- for (Uri uri : contentUris) {
- intentFilter.addDataScheme(uri.getScheme());
- intentFilter.addDataAuthority(uri.getAuthority(), null);
- intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
- }
- return intentFilter;
- } catch (Exception e) {
- Log.e(TAG, "Failed to get target intent filter", e);
- return null;
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
index 2cfceeae..30e69c18 100644
--- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
@@ -22,6 +22,7 @@ import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
+import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentManager;
import com.android.intentresolver.chooser.DisplayResolveInfo;
@@ -62,10 +63,11 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF
@Override
public void onClick(DialogInterface dialog, int which) {
mMultiDisplayResolveInfo.setSelected(which);
- ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true);
+ ((StartsSelectedItem) getActivity()).startSelected(mParentWhich, false, true);
dismiss();
}
+ @NonNull
@Override
protected CharSequence getItemLabel(DisplayResolveInfo dri) {
final PackageManager pm = getContext().getPackageManager();
diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
index 4bfb21aa..8070fc84 100644
--- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
@@ -21,8 +21,6 @@ import static android.content.Context.ACTIVITY_SERVICE;
import static java.util.stream.Collectors.toList;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Dialog;
import android.content.ComponentName;
@@ -35,6 +33,7 @@ import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
@@ -46,6 +45,8 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -136,7 +137,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment
final TargetPresentationGetter pg = getProvidingAppPresentationGetter();
title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel());
- icon.setImageDrawable(pg.getIcon(mUserHandle));
+ icon.setImageDrawable(new BitmapDrawable(getResources(), pg.getIconBitmap(mUserHandle)));
rv.setAdapter(new VHAdapter(items));
return v;
@@ -205,7 +206,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment
} else {
pinComponent(mTargetInfos.get(which).getResolvedComponentName());
}
- ((ChooserActivity) getActivity()).handlePackagesChanged();
+ ((PackagesChangedListener) getActivity()).handlePackagesChanged();
dismiss();
}
@@ -280,7 +281,11 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment
final int iconDpi = am.getLauncherLargeIconDensity();
// Use the matching application icon and label for the title, any TargetInfo will do
- return new TargetPresentationGetter.Factory(getContext(), iconDpi)
+ final Context context = getContext();
+ return new TargetPresentationGetter.Factory(
+ () -> SimpleIconFactory.obtain(context),
+ context.getPackageManager(),
+ iconDpi)
.makePresentationGetter(mTargetInfos.get(0).getResolveInfo());
}
diff --git a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src/com/android/intentresolver/ContentTypeHint.kt
index 6bf7579e..f607e4ae 100644
--- a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
+++ b/java/src/com/android/intentresolver/ContentTypeHint.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,11 +14,12 @@
* limitations under the License.
*/
-package com.android.intentresolver.flags
+package com.android.intentresolver
-import android.content.Context
+import android.content.Intent
-class FeatureFlagRepositoryFactory {
- fun create(context: Context): FeatureFlagRepository =
- ReleaseFeatureFlagRepository(DeviceConfigProxy())
+/** Enum reflecting the value of [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT]. */
+enum class ContentTypeHint {
+ NONE,
+ ALBUM,
}
diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
index b1178aa5..6a4fe65a 100644
--- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
+++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
@@ -21,14 +21,14 @@ import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
import com.android.internal.annotations.VisibleForTesting
+import java.util.function.Supplier
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import java.util.function.Supplier
/**
- * A helper class to track app's readiness for the scene transition animation.
- * The app is ready when both the image is laid out and the drawer offset is calculated.
+ * A helper class to track app's readiness for the scene transition animation. The app is ready when
+ * both the image is laid out and the drawer offset is calculated.
*/
@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
class EnterTransitionAnimationDelegate(
@@ -45,21 +45,22 @@ class EnterTransitionAnimationDelegate(
activity.setEnterSharedElementCallback(
object : SharedElementCallback() {
override fun onMapSharedElements(
- names: MutableList<String>, sharedElements: MutableMap<String, View>
+ names: MutableList<String>,
+ sharedElements: MutableMap<String, View>
) {
- this@EnterTransitionAnimationDelegate.onMapSharedElements(
- names, sharedElements
- )
+ this@EnterTransitionAnimationDelegate.onMapSharedElements(names, sharedElements)
}
- })
+ }
+ )
}
fun postponeTransition() {
activity.postponeEnterTransition()
- timeoutJob = activity.lifecycleScope.launch {
- delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong())
- onTimeout()
- }
+ timeoutJob =
+ activity.lifecycleScope.launch {
+ delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong())
+ onTimeout()
+ }
}
private fun onTimeout() {
@@ -110,8 +111,14 @@ class EnterTransitionAnimationDelegate(
override fun onLayoutChange(
v: View,
- left: Int, top: Int, right: Int, bottom: Int,
- oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ oldLeft: Int,
+ oldTop: Int,
+ oldRight: Int,
+ oldBottom: Int
) {
v.removeOnLayoutChangeListener(this)
startPostponedEnterTransition()
diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
deleted file mode 100644
index a1c53402..00000000
--- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
+++ /dev/null
@@ -1,235 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver;
-
-import android.annotation.Nullable;
-import android.content.Context;
-import android.os.UserHandle;
-import android.util.Log;
-import android.view.View;
-import android.view.ViewGroup;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-import com.google.common.collect.ImmutableList;
-
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-/**
- * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in
- * existing implementations; most overrides were only to vary type signatures (which are better
- * represented via generic types), and a few minor behavioral customizations are now implemented
- * through small injectable delegate classes.
- * TODO: now that the existing implementations are shown to be expressible in terms of this new
- * generic type, merge up into the base class and simplify the public APIs.
- * TODO: attempt to further restrict visibility in the methods we expose.
- * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive"
- * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident
- * waiting to happen since clients seem to make assumptions about which adapter will be "active" in
- * a particular context, and more explicit APIs would make sure those were valid.
- * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?)
- *
- * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter
- * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in
- * the per-profile records.
- * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to
- * control the contents of a given per-profile list. This is provided for convenience, since it must
- * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}.
- *
- * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the
- * type constraint can probably be dropped once the API is merged upwards and cleaned.
- */
-class GenericMultiProfilePagerAdapter<
- PageViewT extends ViewGroup,
- SinglePageAdapterT,
- ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter {
-
- /** Delegate to set up a given adapter and page view to be used together. */
- public interface AdapterBinder<PageViewT, SinglePageAdapterT> {
- /**
- * The given {@code view} will be associated with the given {@code adapter}. Do any work
- * necessary to configure them compatibly, introduce them to each other, etc.
- */
- void bind(PageViewT view, SinglePageAdapterT adapter);
- }
-
- private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor;
- private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder;
- private final Supplier<ViewGroup> mPageViewInflater;
- private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier;
-
- private final ImmutableList<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems;
-
- GenericMultiProfilePagerAdapter(
- Context context,
- Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor,
- AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
- ImmutableList<SinglePageAdapterT> adapters,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle,
- Supplier<ViewGroup> pageViewInflater,
- Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
- super(
- context,
- /* currentPage= */ defaultProfile,
- emptyStateProvider,
- workProfileQuietModeChecker,
- workProfileUserHandle,
- cloneProfileUserHandle);
-
- mListAdapterExtractor = listAdapterExtractor;
- mAdapterBinder = adapterBinder;
- mPageViewInflater = pageViewInflater;
- mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier;
-
- ImmutableList.Builder<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> items =
- new ImmutableList.Builder<>();
- for (SinglePageAdapterT adapter : adapters) {
- items.add(createProfileDescriptor(adapter));
- }
- mItems = items.build();
- }
-
- private GenericProfileDescriptor<PageViewT, SinglePageAdapterT>
- createProfileDescriptor(SinglePageAdapterT adapter) {
- return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter);
- }
-
- @Override
- protected GenericProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) {
- return mItems.get(pageIndex);
- }
-
- @Override
- public int getItemCount() {
- return mItems.size();
- }
-
- public PageViewT getListViewForIndex(int index) {
- return getItem(index).mView;
- }
-
- @Override
- @VisibleForTesting
- public SinglePageAdapterT getAdapterForIndex(int index) {
- return getItem(index).mAdapter;
- }
-
- @Override
- protected void setupListAdapter(int pageIndex) {
- mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex));
- }
-
- @Override
- public ViewGroup instantiateItem(ViewGroup container, int position) {
- setupListAdapter(position);
- return super.instantiateItem(container, position);
- }
-
- @Override
- @Nullable
- protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) {
- if (getPersonalListAdapter().getUserHandle().equals(userHandle)
- || userHandle.equals(getCloneUserHandle())) {
- return getPersonalListAdapter();
- } else if (getWorkListAdapter() != null
- && getWorkListAdapter().getUserHandle().equals(userHandle)) {
- return getWorkListAdapter();
- }
- return null;
- }
-
- @Override
- @VisibleForTesting
- public ListAdapterT getActiveListAdapter() {
- return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage()));
- }
-
- @Override
- @VisibleForTesting
- public ListAdapterT getInactiveListAdapter() {
- if (getCount() < 2) {
- return null;
- }
- return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage()));
- }
-
- @Override
- public ListAdapterT getPersonalListAdapter() {
- return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL));
- }
-
- @Override
- public ListAdapterT getWorkListAdapter() {
- if (!hasAdapterForIndex(PROFILE_WORK)) {
- return null;
- }
- return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK));
- }
-
- @Override
- protected SinglePageAdapterT getCurrentRootAdapter() {
- return getAdapterForIndex(getCurrentPage());
- }
-
- @Override
- protected PageViewT getActiveAdapterView() {
- return getListViewForIndex(getCurrentPage());
- }
-
- @Override
- protected PageViewT getInactiveAdapterView() {
- if (getCount() < 2) {
- return null;
- }
- return getListViewForIndex(1 - getCurrentPage());
- }
-
- @Override
- protected void setupContainerPadding(View container) {
- Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get();
- bottomPaddingOverride.ifPresent(paddingBottom ->
- container.setPadding(
- container.getPaddingLeft(),
- container.getPaddingTop(),
- container.getPaddingRight(),
- paddingBottom));
- }
-
- private boolean hasAdapterForIndex(int pageIndex) {
- return (pageIndex < getCount());
- }
-
- // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager"
- // should be the owner of all per-profile data (especially now that the API is generic)?
- private static class GenericProfileDescriptor<PageViewT, SinglePageAdapterT> extends
- ProfileDescriptor {
- private final SinglePageAdapterT mAdapter;
- private final PageViewT mView;
-
- GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) {
- super(rootView);
- mAdapter = adapter;
- mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list);
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java
index c5826882..30e518fa 100644
--- a/java/src/com/android/intentresolver/IntentForwarderActivity.java
+++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java
@@ -20,10 +20,9 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTEN
import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK;
import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY;
-import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER;
-import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE;
+import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_CALLING_USER;
+import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_SELECTED_PROFILE;
-import android.annotation.Nullable;
import android.app.Activity;
import android.app.ActivityThread;
import android.app.AppGlobals;
@@ -45,6 +44,9 @@ import android.provider.Settings;
import android.util.Slog;
import android.widget.Toast;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
@@ -253,9 +255,9 @@ public class IntentForwarderActivity extends Activity {
private int findSelectedProfile(String className) {
if (className.equals(FORWARD_INTENT_TO_PARENT)) {
- return ChooserActivity.PROFILE_PERSONAL;
+ return MultiProfilePagerAdapter.PROFILE_PERSONAL;
} else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) {
- return ChooserActivity.PROFILE_WORK;
+ return MultiProfilePagerAdapter.PROFILE_WORK;
}
return -1;
}
diff --git a/java/src/com/android/intentresolver/IntentForwarding.kt b/java/src/com/android/intentresolver/IntentForwarding.kt
new file mode 100644
index 00000000..c8f6cf41
--- /dev/null
+++ b/java/src/com/android/intentresolver/IntentForwarding.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.Manifest
+import android.Manifest.permission.INTERACT_ACROSS_USERS
+import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
+import android.app.ActivityManager
+import android.content.Context
+import android.content.Intent
+import android.content.PermissionChecker
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.os.UserHandle
+import android.os.UserManager
+import android.util.Log
+import com.android.intentresolver.data.repository.DevicePolicyResources
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG: String = "IntentForwarding"
+
+@Singleton
+class IntentForwarding
+@Inject
+constructor(
+ private val resources: DevicePolicyResources,
+ private val userManager: UserManager,
+ private val packageManager: PackageManager
+) {
+
+ fun forwardMessageFor(intent: Intent): String? {
+ val contentUserHint = intent.contentUserHint
+ if (
+ contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId()
+ ) {
+ val originUserInfo = userManager.getUserInfo(contentUserHint)
+ val originIsManaged = originUserInfo?.isManagedProfile ?: false
+ val targetIsManaged = userManager.isManagedProfile
+ return when {
+ originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage
+ !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage
+ else -> null
+ }
+ }
+ return null
+ }
+
+ private fun isPermissionGranted(permission: String, uid: Int) =
+ ActivityManager.checkComponentPermission(
+ /* permission = */ permission,
+ /* uid = */ uid,
+ /* owningUid= */ -1,
+ /* exported= */ true
+ )
+
+ /**
+ * Returns whether the package has the necessary permissions to interact across profiles on
+ * behalf of a given user.
+ *
+ * This means meeting the following condition:
+ * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the
+ * following conditions must be fulfilled
+ * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted.
+ * * `Manifest.permission.INTERACT_ACROSS_USERS` granted.
+ * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps
+ * `android:interact_across_profiles` is set to "allow".
+ */
+ fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean {
+ val applicationInfo: ApplicationInfo
+ try {
+ applicationInfo = packageManager.getApplicationInfo(packageName, 0)
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.e(TAG, "Package $packageName does not exist on current user.")
+ return false
+ }
+ if (!applicationInfo.crossProfile) {
+ return false
+ }
+
+ val packageUid = applicationInfo.uid
+
+ if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) {
+ return true
+ }
+ if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) {
+ return true
+ }
+ return PermissionChecker.checkPermissionForPreflight(
+ context,
+ Manifest.permission.INTERACT_ACROSS_PROFILES,
+ PermissionChecker.PID_UNKNOWN,
+ packageUid,
+ packageName
+ ) == PERMISSION_GRANTED
+ }
+}
diff --git a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt
index d3e07c6b..7deb0d10 100644
--- a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt
+++ b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt
@@ -37,9 +37,7 @@ internal class ItemRevealAnimationTracker {
fun animateLabel(view: View, info: TargetInfo) = animateView(view, info, labelProgress)
private fun animateView(view: View, info: TargetInfo, map: MutableMap<TargetInfo, Record>) {
- val record = map.getOrPut(info) {
- Record()
- }
+ val record = map.getOrPut(info) { Record() }
if ((view.animation as? RevealAnimation)?.record === record) return
view.clearAnimation()
diff --git a/java/src/com/android/intentresolver/JavaFlowHelper.kt b/java/src/com/android/intentresolver/JavaFlowHelper.kt
new file mode 100644
index 00000000..231cb809
--- /dev/null
+++ b/java/src/com/android/intentresolver/JavaFlowHelper.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("JavaFlowHelper")
+
+package com.android.intentresolver
+
+import com.android.intentresolver.annotation.JavaInterop
+import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+@JavaInterop
+fun <T> collect(scope: CoroutineScope, flow: Flow<T>, collector: Consumer<T>): Job =
+ scope.launch { flow.collect { collector.accept(it) } }
diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/src/com/android/intentresolver/MainApplication.kt
index 1ddf7462..0a826629 100644
--- a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt
+++ b/java/src/com/android/intentresolver/MainApplication.kt
@@ -16,8 +16,7 @@
package com.android.intentresolver
-/**
- * Specifies expected feature flag values for a test.
- */
-@Target(AnnotationTarget.FUNCTION)
-annotation class RequireFeatureFlags(val flags: Array<String>, val values: BooleanArray)
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp(Application::class) open class MainApplication : Hilt_MainApplication()
diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
deleted file mode 100644
index a7b50f38..00000000
--- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver;
-
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS;
-
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.app.admin.DevicePolicyEventLogger;
-import android.app.admin.DevicePolicyManager;
-import android.content.Context;
-import android.content.pm.ResolveInfo;
-import android.os.UserHandle;
-import android.stats.devicepolicy.nano.DevicePolicyEnums;
-
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
-import com.android.internal.R;
-
-import java.util.List;
-
-/**
- * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
- * there are no apps available.
- */
-public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider {
-
- @NonNull
- private final Context mContext;
- @Nullable
- private final UserHandle mWorkProfileUserHandle;
- @Nullable
- private final UserHandle mPersonalProfileUserHandle;
- @NonNull
- private final String mMetricsCategory;
- @NonNull
- private final UserHandle mTabOwnerUserHandleForLaunch;
-
- public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle,
- UserHandle personalProfileUserHandle, String metricsCategory,
- UserHandle tabOwnerUserHandleForLaunch) {
- mContext = context;
- mWorkProfileUserHandle = workProfileUserHandle;
- mPersonalProfileUserHandle = personalProfileUserHandle;
- mMetricsCategory = metricsCategory;
- mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch;
- }
-
- @Nullable
- @Override
- @SuppressWarnings("ReferenceEquality")
- public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- UserHandle listUserHandle = resolverListAdapter.getUserHandle();
-
- if (mWorkProfileUserHandle != null
- && (mTabOwnerUserHandleForLaunch.equals(listUserHandle)
- || !hasAppsInOtherProfile(resolverListAdapter))) {
-
- String title;
- if (listUserHandle == mPersonalProfileUserHandle) {
- title = mContext.getSystemService(
- DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_PERSONAL_APPS,
- () -> mContext.getString(R.string.resolver_no_personal_apps_available));
- } else {
- title = mContext.getSystemService(
- DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_WORK_APPS,
- () -> mContext.getString(R.string.resolver_no_work_apps_available));
- }
-
- return new NoAppsAvailableEmptyState(
- title, mMetricsCategory,
- /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle
- );
- } else if (mWorkProfileUserHandle == null) {
- // Return default empty state without tracking
- return new DefaultEmptyState();
- }
-
- return null;
- }
-
- private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) {
- if (mWorkProfileUserHandle == null) {
- return false;
- }
- List<ResolvedComponentInfo> resolversForIntent =
- adapter.getResolversForUser(mTabOwnerUserHandleForLaunch);
- for (ResolvedComponentInfo info : resolversForIntent) {
- ResolveInfo resolveInfo = info.getResolveInfoAt(0);
- if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) {
- return true;
- }
- }
- return false;
- }
-
- public static class DefaultEmptyState implements EmptyState {
- @Override
- public boolean useDefaultEmptyView() {
- return true;
- }
- }
-
- public static class NoAppsAvailableEmptyState implements EmptyState {
-
- @NonNull
- private String mTitle;
-
- @NonNull
- private String mMetricsCategory;
-
- private boolean mIsPersonalProfile;
-
- public NoAppsAvailableEmptyState(String title, String metricsCategory,
- boolean isPersonalProfile) {
- mTitle = title;
- mMetricsCategory = metricsCategory;
- mIsPersonalProfile = isPersonalProfile;
- }
-
- @Nullable
- @Override
- public String getTitle() {
- return mTitle;
- }
-
- @Override
- public void onEmptyStateShown() {
- DevicePolicyEventLogger.createEvent(
- DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED)
- .setStrings(mMetricsCategory)
- .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile)
- .write();
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java
deleted file mode 100644
index 6f72bb00..00000000
--- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver;
-
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.annotation.StringRes;
-import android.app.admin.DevicePolicyEventLogger;
-import android.app.admin.DevicePolicyManager;
-import android.content.Context;
-import android.os.UserHandle;
-
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
-
-/**
- * Empty state provider that does not allow cross profile sharing, it will return a blocker
- * in case if the profile of the current tab is not the same as the profile of the calling app.
- */
-public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider {
-
- private final UserHandle mPersonalProfileUserHandle;
- private final EmptyState mNoWorkToPersonalEmptyState;
- private final EmptyState mNoPersonalToWorkEmptyState;
- private final CrossProfileIntentsChecker mCrossProfileIntentsChecker;
- private final UserHandle mTabOwnerUserHandleForLaunch;
-
- public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle,
- EmptyState noWorkToPersonalEmptyState,
- EmptyState noPersonalToWorkEmptyState,
- CrossProfileIntentsChecker crossProfileIntentsChecker,
- UserHandle tabOwnerUserHandleForLaunch) {
- mPersonalProfileUserHandle = personalUserHandle;
- mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState;
- mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState;
- mCrossProfileIntentsChecker = crossProfileIntentsChecker;
- mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch;
- }
-
- @Nullable
- @Override
- public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- boolean shouldShowBlocker =
- !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle())
- && !mCrossProfileIntentsChecker
- .hasCrossProfileIntents(resolverListAdapter.getIntents(),
- mTabOwnerUserHandleForLaunch.getIdentifier(),
- resolverListAdapter.getUserHandle().getIdentifier());
-
- if (!shouldShowBlocker) {
- return null;
- }
-
- if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) {
- return mNoWorkToPersonalEmptyState;
- } else {
- return mNoPersonalToWorkEmptyState;
- }
- }
-
-
- /**
- * Empty state that gets strings from the device policy manager and tracks events into
- * event logger of the device policy events.
- */
- public static class DevicePolicyBlockerEmptyState implements EmptyState {
-
- @NonNull
- private final Context mContext;
- private final String mDevicePolicyStringTitleId;
- @StringRes
- private final int mDefaultTitleResource;
- private final String mDevicePolicyStringSubtitleId;
- @StringRes
- private final int mDefaultSubtitleResource;
- private final int mEventId;
- @NonNull
- private final String mEventCategory;
-
- public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId,
- @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId,
- @StringRes int defaultSubtitleResource,
- int devicePolicyEventId, String devicePolicyEventCategory) {
- mContext = context;
- mDevicePolicyStringTitleId = devicePolicyStringTitleId;
- mDefaultTitleResource = defaultTitleResource;
- mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId;
- mDefaultSubtitleResource = defaultSubtitleResource;
- mEventId = devicePolicyEventId;
- mEventCategory = devicePolicyEventCategory;
- }
-
- @Nullable
- @Override
- public String getTitle() {
- return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
- mDevicePolicyStringTitleId,
- () -> mContext.getString(mDefaultTitleResource));
- }
-
- @Nullable
- @Override
- public String getSubtitle() {
- return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
- mDevicePolicyStringSubtitleId,
- () -> mContext.getString(mDefaultSubtitleResource));
- }
-
- @Override
- public void onEmptyStateShown() {
- DevicePolicyEventLogger.createEvent(mEventId)
- .setStrings(mEventCategory)
- .write();
- }
-
- @Override
- public boolean shouldSkipDataRebuild() {
- return true;
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/PackagesChangedListener.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt
new file mode 100644
index 00000000..10f0bf51
--- /dev/null
+++ b/java/src/com/android/intentresolver/PackagesChangedListener.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+/** A component which can be notified when packages have changed. */
+interface PackagesChangedListener {
+ /** Report that packages have changed. */
+ fun handlePackagesChanged()
+}
diff --git a/java/src/com/android/intentresolver/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt
new file mode 100644
index 00000000..43982727
--- /dev/null
+++ b/java/src/com/android/intentresolver/ProfileAvailability.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import androidx.annotation.MainThread
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.shared.model.Profile
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+/** Provides availability status for profiles */
+@JavaInterop
+class ProfileAvailability(
+ private val userInteractor: UserInteractor,
+ private val scope: CoroutineScope,
+ private val background: CoroutineDispatcher,
+) {
+ /** Used by WorkProfilePausedEmptyStateProvider */
+ var waitingToEnableProfile = false
+ private set
+
+ /** Set by ChooserActivity to call onWorkProfileStatusUpdated */
+ var onProfileStatusChange: Runnable? = null
+
+ private var waitJob: Job? = null
+
+ /** Query current profile availability. An unavailable profile is one which is not active. */
+ @MainThread
+ fun isAvailable(profile: Profile?): Boolean {
+ return runBlocking(background) {
+ userInteractor.availability.map { it[profile] == true }.first()
+ }
+ }
+
+ /**
+ * The number of profiles which are visible. All profiles count except for private which is
+ * hidden when locked.
+ */
+ fun visibleProfileCount() =
+ runBlocking(background) {
+ val availability = userInteractor.availability.first()
+ val profiles = userInteractor.profiles.first()
+ profiles
+ .filter {
+ when (it.type) {
+ Profile.Type.PRIVATE -> availability[it] == true
+ else -> true
+ }
+ }
+ .size
+ }
+
+ /** Used by WorkProfilePausedEmptyStateProvider */
+ fun requestQuietModeState(profile: Profile, quietMode: Boolean) {
+ val enableProfile = !quietMode
+
+ // Check if the profile is already in the correct state
+ if (isAvailable(profile) == enableProfile) {
+ return // No-op
+ }
+
+ // Support existing code
+ if (enableProfile) {
+ waitingToEnableProfile = true
+ waitJob?.cancel()
+
+ val job =
+ scope.launch {
+ // Wait for the profile to become available
+ userInteractor.availability.filter { it[profile] == true }.first()
+ }
+ job.invokeOnCompletion {
+ waitingToEnableProfile = false
+ onProfileStatusChange?.run()
+ }
+ waitJob = job
+ }
+
+ // Apply the change
+ scope.launch { userInteractor.updateState(profile, enableProfile) }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ProfileHelper.kt b/java/src/com/android/intentresolver/ProfileHelper.kt
new file mode 100644
index 00000000..b87f7e3f
--- /dev/null
+++ b/java/src/com/android/intentresolver/ProfileHelper.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.os.UserHandle
+import androidx.annotation.MainThread
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.shared.model.User
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+
+@JavaInterop
+@MainThread
+class ProfileHelper
+@Inject
+constructor(interactor: UserInteractor, private val background: CoroutineDispatcher) {
+ private val launchedByHandle: UserHandle = interactor.launchedAs
+
+ val launchedAsProfile by lazy {
+ runBlocking(background) { interactor.launchedAsProfile.first() }
+ }
+ val profiles by lazy { runBlocking(background) { interactor.profiles.first() } }
+
+ // Map UserHandle back to a user within launchedByProfile
+ private val launchedByUser: User =
+ when (launchedByHandle) {
+ launchedAsProfile.primary.handle -> launchedAsProfile.primary
+ launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone)
+ else -> error("launchedByUser must be a member of launchedByProfile")
+ }
+ val launchedAsProfileType: Profile.Type = launchedAsProfile.type
+
+ val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL }
+ val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK }
+ val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE }
+
+ val personalHandle = personalProfile.primary.handle
+ val workHandle = workProfile?.primary?.handle
+ val privateHandle = privateProfile?.primary?.handle
+ val cloneHandle = personalProfile.clone?.handle
+
+ val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone
+
+ val cloneUserPresent = personalProfile.clone != null
+ val workProfilePresent = workProfile != null
+ val privateProfilePresent = privateProfile != null
+
+ // Name retained for ease of review, to be renamed later
+ val tabOwnerUserHandleForLaunch =
+ if (launchedByUser.role == User.Role.CLONE) {
+ // When started by clone user, return the profile owner instead
+ launchedAsProfile.primary.handle
+ } else {
+ // Otherwise the launched user is used
+ launchedByUser.handle
+ }
+
+ fun findProfile(handle: UserHandle): Profile? {
+ return profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle }
+ }
+
+ fun findProfileType(handle: UserHandle): Profile.Type? = findProfile(handle)?.type
+
+ // Name retained for ease of review, to be renamed later
+ fun getQueryIntentsHandle(handle: UserHandle): UserHandle? {
+ return if (isLaunchedAsCloneProfile && handle == personalHandle) {
+ cloneHandle
+ } else {
+ handle
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
index ecb72cbf..aaa97c42 100644
--- a/java/src/com/android/intentresolver/ResolvedComponentInfo.java
+++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
@@ -20,6 +20,8 @@ import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.ResolveInfo;
+import com.android.intentresolver.chooser.TargetInfo;
+
import java.util.ArrayList;
import java.util.List;
@@ -86,7 +88,7 @@ public final class ResolvedComponentInfo {
}
/**
- * @return whether this component was pinned by a call to {@link #setPinned()}.
+ * @return whether this component was pinned by a call to {@link #setPinned}.
* TODO: consolidate sources of pinning data and/or document how this differs from other places
* we make a "pinning" determination.
*/
diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java
index 35c7e897..a63b3a98 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -16,42 +16,25 @@
package com.android.intentresolver;
-import static android.Manifest.permission.INTERACT_ACROSS_PROFILES;
-import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY;
-import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
-import static android.content.PermissionChecker.PID_UNKNOWN;
-import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL;
-import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK;
import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
+import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
+
+import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs;
import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED;
-import android.annotation.Nullable;
-import android.annotation.StringRes;
-import android.annotation.UiThread;
-import android.app.Activity;
-import android.app.ActivityManager;
+import static java.util.Objects.requireNonNull;
+
import android.app.ActivityThread;
import android.app.VoiceInteractor.PickOptionRequest;
import android.app.VoiceInteractor.PickOptionRequest.Option;
import android.app.VoiceInteractor.Prompt;
import android.app.admin.DevicePolicyEventLogger;
-import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
-import android.content.PermissionChecker;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
@@ -59,7 +42,6 @@ import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.content.pm.UserInfo;
import android.content.res.Configuration;
-import android.content.res.TypedArray;
import android.graphics.Insets;
import android.net.Uri;
import android.os.Build;
@@ -70,7 +52,6 @@ import android.os.StrictMode;
import android.os.Trace;
import android.os.UserHandle;
import android.os.UserManager;
-import android.provider.MediaStore;
import android.provider.Settings;
import android.stats.devicepolicy.DevicePolicyEnums;
import android.text.TextUtils;
@@ -92,40 +73,63 @@ import android.widget.ImageView;
import android.widget.ListView;
import android.widget.Space;
import android.widget.TabHost;
-import android.widget.TabWidget;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.viewmodel.CreationExtras;
import androidx.viewpager.widget.ViewPager;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile;
-import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.data.repository.ActivityModelRepository;
+import com.android.intentresolver.data.repository.DevicePolicyResources;
+import com.android.intentresolver.domain.interactor.UserInteractor;
+import com.android.intentresolver.emptystate.CompositeEmptyStateProvider;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
+import com.android.intentresolver.emptystate.EmptyStateProvider;
+import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider;
+import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider;
+import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider;
import com.android.intentresolver.icons.DefaultTargetDataLoader;
import com.android.intentresolver.icons.TargetDataLoader;
+import com.android.intentresolver.inject.Background;
import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter;
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType;
+import com.android.intentresolver.profiles.OnProfileSelectedListener;
+import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener;
+import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter;
+import com.android.intentresolver.profiles.TabConfig;
+import com.android.intentresolver.shared.model.ActivityModel;
+import com.android.intentresolver.shared.model.Profile;
+import com.android.intentresolver.ui.ActionTitle;
+import com.android.intentresolver.ui.ProfilePagerResources;
+import com.android.intentresolver.ui.model.ResolverRequest;
+import com.android.intentresolver.ui.viewmodel.ResolverViewModel;
import com.android.intentresolver.widget.ResolverDrawerLayout;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.content.PackageMonitor;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
-import com.android.internal.util.LatencyTracker;
+
+import com.google.common.collect.ImmutableList;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+import kotlinx.coroutines.CoroutineDispatcher;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
-import java.util.function.Supplier;
+
+import javax.inject.Inject;
/**
* This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is
@@ -133,47 +137,35 @@ import java.util.function.Supplier;
* frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full
* migration is not complete.
*/
-@UiThread
-public class ResolverActivity extends FragmentActivity implements
+@AndroidEntryPoint(FragmentActivity.class)
+public class ResolverActivity extends Hilt_ResolverActivity implements
ResolverListAdapter.ResolverListCommunicator {
- public ResolverActivity() {
- mIsIntentPicker = getClass().equals(ResolverActivity.class);
- }
-
- protected ResolverActivity(boolean isIntentPicker) {
- mIsIntentPicker = isIntentPicker;
- }
-
- /**
- * Whether to enable a launch mode that is safe to use when forwarding intents received from
- * applications and running in system processes. This mode uses Activity.startActivityAsCaller
- * instead of the normal Activity.startActivity for launching the activity selected
- * by the user.
- */
- private boolean mSafeForwardingMode;
+ @Inject @Background public CoroutineDispatcher mBackgroundDispatcher;
+ @Inject public UserInteractor mUserInteractor;
+ @Inject public ResolverHelper mResolverHelper;
+ @Inject public PackageManager mPackageManager;
+ @Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public ProfilePagerResources mProfilePagerResources;
+ @Inject public IntentForwarding mIntentForwarding;
+ @Inject public ActivityModelRepository mActivityModelRepository;
+ @Inject public DefaultTargetDataLoader.Factory mTargetDataLoaderFactory;
+
+ private ResolverViewModel mViewModel;
+ private ResolverRequest mRequest;
+ private ProfileHelper mProfiles;
+ private ProfileAvailability mProfileAvailability;
+ protected TargetDataLoader mTargetDataLoader;
+ private boolean mResolvingHome;
private Button mAlwaysButton;
private Button mOnceButton;
protected View mProfileView;
private int mLastSelected = AbsListView.INVALID_POSITION;
- private boolean mResolvingHome = false;
- private String mProfileSwitchMessage;
private int mLayoutId;
- @VisibleForTesting
- protected final ArrayList<Intent> mIntents = new ArrayList<>();
private PickTargetOptionRequest mPickOptionRequest;
- private String mReferrerPackage;
- private CharSequence mTitle;
- private int mDefaultTitleResId;
// Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity.
- private final boolean mIsIntentPicker;
-
- // Whether or not this activity supports choosing a default handler for the intent.
- @VisibleForTesting
- protected boolean mSupportsAlwaysUseOption;
protected ResolverDrawerLayout mResolverDrawerLayout;
- protected PackageManager mPm;
private static final String TAG = "ResolverActivity";
private static final boolean DEBUG = false;
@@ -184,139 +176,32 @@ public class ResolverActivity extends FragmentActivity implements
protected Insets mSystemWindowInsets = null;
private Space mFooterSpacer = null;
- /** See {@link #setRetainInOnStop}. */
- private boolean mRetainInOnStop;
-
protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver";
- protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser";
/** Tracks if we should ignore future broadcasts telling us the work profile is enabled */
- private boolean mWorkProfileHasBeenEnabled = false;
+ private final boolean mWorkProfileHasBeenEnabled = false;
- private static final String TAB_TAG_PERSONAL = "personal";
- private static final String TAB_TAG_WORK = "work";
+ protected static final String TAB_TAG_PERSONAL = "personal";
+ protected static final String TAB_TAG_WORK = "work";
private PackageMonitor mPersonalPackageMonitor;
private PackageMonitor mWorkPackageMonitor;
- @VisibleForTesting
- protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter;
-
- protected WorkProfileAvailabilityManager mWorkProfileAvailability;
+ protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter;
- // Intent extra for connected audio devices
- public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device";
-
- /**
- * Integer extra to indicate which profile should be automatically selected.
- * <p>Can only be used if there is a work profile.
- * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}.
- */
- protected static final String EXTRA_SELECTED_PROFILE =
- "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE";
-
- /**
- * {@link UserHandle} extra to indicate the user of the user that the starting intent
- * originated from.
- * <p>This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()},
- * as there are edge cases when the intent resolver is launched in the other profile.
- * For example, when we have 0 resolved apps in current profile and multiple resolved
- * apps in the other profile, opening a link from the current profile launches the intent
- * resolver in the other one. b/148536209 for more info.
- */
- static final String EXTRA_CALLING_USER =
- "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER";
-
- protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL;
- protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK;
+ public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL;
+ public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK;
private UserHandle mHeaderCreatorUser;
- // User handle annotations are lazy-initialized to ensure that they're computed exactly once
- // (even though they can't be computed prior to activity creation).
- // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or
- // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a
- // new component whose lifecycle is limited to the "created" Activity (so that we can just hold
- // the annotations as a `final` ivar, which is a better way to show immutability).
- private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> {
- final AnnotatedUserHandles result = AnnotatedUserHandles.forShareActivity(this);
- mLazyAnnotatedUserHandles = () -> result;
- return result;
- };
-
@Nullable
private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
- protected final LatencyTracker mLatencyTracker = getLatencyTracker();
-
- private enum ActionTitle {
- VIEW(Intent.ACTION_VIEW,
- R.string.whichViewApplication,
- R.string.whichViewApplicationNamed,
- R.string.whichViewApplicationLabel),
- EDIT(Intent.ACTION_EDIT,
- R.string.whichEditApplication,
- R.string.whichEditApplicationNamed,
- R.string.whichEditApplicationLabel),
- SEND(Intent.ACTION_SEND,
- R.string.whichSendApplication,
- R.string.whichSendApplicationNamed,
- R.string.whichSendApplicationLabel),
- SENDTO(Intent.ACTION_SENDTO,
- R.string.whichSendToApplication,
- R.string.whichSendToApplicationNamed,
- R.string.whichSendToApplicationLabel),
- SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE,
- R.string.whichSendApplication,
- R.string.whichSendApplicationNamed,
- R.string.whichSendApplicationLabel),
- CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE,
- R.string.whichImageCaptureApplication,
- R.string.whichImageCaptureApplicationNamed,
- R.string.whichImageCaptureApplicationLabel),
- DEFAULT(null,
- R.string.whichApplication,
- R.string.whichApplicationNamed,
- R.string.whichApplicationLabel),
- HOME(Intent.ACTION_MAIN,
- R.string.whichHomeApplication,
- R.string.whichHomeApplicationNamed,
- R.string.whichHomeApplicationLabel);
-
- // titles for layout that deals with http(s) intents
- public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith;
- public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith;
- public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp;
- public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp;
-
- public final String action;
- public final int titleRes;
- public final int namedTitleRes;
- public final @StringRes int labelRes;
-
- ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) {
- this.action = action;
- this.titleRes = titleRes;
- this.namedTitleRes = namedTitleRes;
- this.labelRes = labelRes;
- }
-
- public static ActionTitle forAction(String action) {
- for (ActionTitle title : values()) {
- if (title != HOME && action != null && action.equals(title.action)) {
- return title;
- }
- }
- return DEFAULT;
- }
- }
-
protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) {
return new PackageMonitor() {
@Override
public void onSomePackagesChanged() {
listAdapter.handlePackagesChanged();
- updateProfileViewButton();
}
@Override
@@ -328,121 +213,163 @@ public class ResolverActivity extends FragmentActivity implements
};
}
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- // Use a specialized prompt when we're handling the 'Home' app startActivity()
- final Intent intent = makeMyIntent();
- final Set<String> categories = intent.getCategories();
- if (Intent.ACTION_MAIN.equals(intent.getAction())
- && categories != null
- && categories.size() == 1
- && categories.contains(Intent.CATEGORY_HOME)) {
- // Note: this field is not set to true in the compatibility version.
- mResolvingHome = true;
- }
-
- onCreate(
- savedInstanceState,
- intent,
- /* additionalTargets= */ null,
- /* title= */ null,
- /* defaultTitleRes= */ 0,
- /* initialIntents= */ null,
- /* resolutionList= */ null,
- /* supportsAlwaysUseOption= */ true,
- createIconLoader(),
- /* safeForwardingMode= */ true);
+ protected ActivityModel createActivityModel() {
+ return ActivityModel.createFrom(this);
}
- /**
- * Compatibility version for other bundled services that use this overload without
- * a default title resource
- */
- protected void onCreate(
- Bundle savedInstanceState,
- Intent intent,
- CharSequence title,
- Intent[] initialIntents,
- List<ResolveInfo> resolutionList,
- boolean supportsAlwaysUseOption,
- boolean safeForwardingMode) {
- onCreate(
- savedInstanceState,
- intent,
- null,
- title,
- 0,
- initialIntents,
- resolutionList,
- supportsAlwaysUseOption,
- createIconLoader(),
- safeForwardingMode);
+ @NonNull
+ @Override
+ public CreationExtras getDefaultViewModelCreationExtras() {
+ return replaceDefaultArgs(super.getDefaultViewModelCreationExtras());
}
- protected void onCreate(
- Bundle savedInstanceState,
- Intent intent,
- Intent[] additionalTargets,
- CharSequence title,
- int defaultTitleRes,
- Intent[] initialIntents,
- List<ResolveInfo> resolutionList,
- boolean supportsAlwaysUseOption,
- TargetDataLoader targetDataLoader,
- boolean safeForwardingMode) {
- setTheme(appliedThemeResId());
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ Log.i(TAG, "onCreate");
+ mActivityModelRepository.initialize(this::createActivityModel);
+ setTheme(R.style.Theme_DeviceDefault_Resolver);
+ mResolverHelper.setInitializer(this::initialize);
+ }
- // Determine whether we should show that intent is forwarded
- // from managed profile to owner or other way around.
- setProfileSwitchMessage(intent.getContentUserHint());
+ @Override
+ protected final void onStart() {
+ super.onStart();
+ this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
+ }
- // Force computation of user handle annotations in order to validate the caller ID. (See the
- // associated TODO comment to explain why this is structured as a lazy computation.)
- AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get();
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ final Window window = this.getWindow();
+ final WindowManager.LayoutParams attrs = window.getAttributes();
+ attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
+ window.setAttributes(attrs);
- mWorkProfileAvailability = createWorkProfileAvailabilityManager();
+ if (mRegistered) {
+ mPersonalPackageMonitor.unregister();
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mRegistered = false;
+ }
+ final Intent intent = getIntent();
+ if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
+ && !mResolvingHome) {
+ // This resolver is in the unusual situation where it has been
+ // launched at the top of a new task. We don't let it be added
+ // to the recent tasks shown to the user, and we need to make sure
+ // that each time we are launched we get the correct launching
+ // uid (not re-using the same resolver from an old launching uid),
+ // so we will now finish ourself since being no longer visible,
+ // the user probably can't get back to us.
+ if (!isChangingConfigurations()) {
+ finish();
+ }
+ }
+ }
- mPm = getPackageManager();
+ @Override
+ protected final void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem());
+ }
+ }
- mReferrerPackage = getReferrerPackageName();
+ @Override
+ protected final void onRestart() {
+ super.onRestart();
+ if (!mRegistered) {
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getPersonalHandle(),
+ false);
+ if (mProfiles.getWorkProfilePresent()) {
+ if (mWorkPackageMonitor == null) {
+ mWorkPackageMonitor = createPackageMonitor(
+ mMultiProfilePagerAdapter.getWorkListAdapter());
+ }
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getWorkHandle(),
+ false);
+ }
+ mRegistered = true;
+ }
+ mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+ }
- // The initial intent must come before any other targets that are to be added.
- mIntents.add(0, new Intent(intent));
- if (additionalTargets != null) {
- Collections.addAll(mIntents, additionalTargets);
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (!isChangingConfigurations() && mPickOptionRequest != null) {
+ mPickOptionRequest.cancel();
+ }
+ if (mMultiProfilePagerAdapter != null
+ && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
+ mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
}
+ }
+
+ private void initialize() {
+ mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class);
+ mRequest = mViewModel.getRequest().getValue();
+
+ mProfiles = new ProfileHelper(
+ mUserInteractor,
+ mBackgroundDispatcher);
+
+ mProfileAvailability = new ProfileAvailability(
+ mUserInteractor,
+ getCoroutineScope(getLifecycle()),
+ mBackgroundDispatcher);
- mTitle = title;
- mDefaultTitleResId = defaultTitleRes;
+ mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated);
- mSupportsAlwaysUseOption = supportsAlwaysUseOption;
- mSafeForwardingMode = safeForwardingMode;
+ mResolvingHome = mRequest.isResolvingHome();
+ mTargetDataLoader = mTargetDataLoaderFactory.create(mRequest.isAudioCaptureDevice());
// The last argument of createResolverListAdapter is whether to do special handling
// of the last used choice to highlight it in the list. We need to always
// turn this off when running under voice interaction, since it results in
// a more complicated UI that the current voice interaction flow is not able
- // to handle. We also turn it off when the work tab is shown to simplify the UX.
+ // to handle. We also turn it off when multiple tabs are shown to simplify the UX.
// We also turn it off when clonedProfile is present on the device, because we might have
// different "last chosen" activities in the different profiles, and PackageManager doesn't
// provide any more information to help us select between them.
- boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction()
- && !shouldShowTabs() && !hasCloneProfile();
+ boolean filterLastUsed = !isVoiceInteraction()
+ && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent();
mMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
- initialIntents, resolutionList, filterLastUsed, targetDataLoader);
- if (configureContentView(targetDataLoader)) {
+ new Intent[0],
+ /* resolutionList = */ mRequest.getResolutionList(),
+ filterLastUsed
+ );
+ if (configureContentView(mTargetDataLoader)) {
return;
}
mPersonalPackageMonitor = createPackageMonitor(
mMultiProfilePagerAdapter.getPersonalListAdapter());
mPersonalPackageMonitor.register(
- this, getMainLooper(), getPersonalProfileUserHandle(), false);
- if (shouldShowTabs()) {
+ this,
+ getMainLooper(),
+ mProfiles.getPersonalHandle(),
+ false
+ );
+ if (mProfiles.getWorkProfilePresent()) {
mWorkPackageMonitor = createPackageMonitor(
mMultiProfilePagerAdapter.getWorkListAdapter());
- mWorkPackageMonitor.register(this, getMainLooper(), getWorkProfileUserHandle(), false);
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ mProfiles.getWorkHandle(),
+ false
+ );
}
mRegistered = true;
@@ -456,7 +383,7 @@ public class ResolverActivity extends FragmentActivity implements
}
});
- boolean hasTouchScreen = getPackageManager()
+ boolean hasTouchScreen = mPackageManager
.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN);
if (isVoiceInteraction() || !hasTouchScreen) {
@@ -469,13 +396,7 @@ public class ResolverActivity extends FragmentActivity implements
mResolverDrawerLayout = rdl;
}
-
- mProfileView = findViewById(com.android.internal.R.id.profile_button);
- if (mProfileView != null) {
- mProfileView.setOnClickListener(this::onProfileClick);
- updateProfileViewButton();
- }
-
+ Intent intent = mViewModel.getRequest().getValue().getIntent();
final Set<String> categories = intent.getCategories();
MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED
@@ -484,61 +405,47 @@ public class ResolverActivity extends FragmentActivity implements
+ (categories != null ? Arrays.toString(categories.toArray()) : ""));
}
- protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter(
+ private void restore(@Nullable Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ // onRestoreInstanceState
+ resetButtonBar();
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ }
+ }
+
+ mMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+
+ protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter(
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
- if (shouldShowTabs()) {
+ boolean filterLastUsed) {
+ ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
+ if (mProfiles.getWorkProfilePresent()) {
resolverMultiProfilePagerAdapter =
createResolverMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, resolutionList, filterLastUsed, targetDataLoader);
+ initialIntents, resolutionList, filterLastUsed);
} else {
resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile(
- initialIntents, resolutionList, filterLastUsed, targetDataLoader);
+ initialIntents, resolutionList, filterLastUsed);
}
return resolverMultiProfilePagerAdapter;
}
protected EmptyStateProvider createBlockerEmptyStateProvider() {
- final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser());
+ boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser());
if (!shouldShowNoCrossProfileIntentsEmptyState) {
// Implementation that doesn't show any blockers
return new EmptyStateProvider() {};
}
-
- final AbstractMultiProfilePagerAdapter.EmptyState
- noWorkToPersonalEmptyState =
- new DevicePolicyBlockerEmptyState(/* context= */ this,
- /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
- /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL,
- /* defaultSubtitleResource= */
- R.string.resolver_cant_access_personal_apps_explanation,
- /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
- /* devicePolicyEventCategory= */
- ResolverActivity.METRICS_CATEGORY_RESOLVER);
-
- final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState =
- new DevicePolicyBlockerEmptyState(/* context= */ this,
- /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
- /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK,
- /* defaultSubtitleResource= */
- R.string.resolver_cant_access_work_apps_explanation,
- /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
- /* devicePolicyEventCategory= */
- ResolverActivity.METRICS_CATEGORY_RESOLVER);
-
- return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
- noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
- createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch());
- }
-
- protected int appliedThemeResId() {
- return R.style.Theme_DeviceDefault_Resolver;
+ return new NoCrossProfileEmptyStateProvider(
+ mProfiles,
+ mDevicePolicyResources,
+ createCrossProfileIntentsChecker(),
+ /* isShare= */ false);
}
/**
@@ -550,9 +457,7 @@ public class ResolverActivity extends FragmentActivity implements
if (useLayoutWithDefault()) return true;
View buttonBar = findViewById(com.android.internal.R.id.button_bar);
- if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true;
-
- return false;
+ return buttonBar == null || buttonBar.getVisibility() == View.GONE;
}
protected void applyFooterView(int height) {
@@ -560,12 +465,12 @@ public class ResolverActivity extends FragmentActivity implements
mFooterSpacer = new Space(getApplicationContext());
} else {
((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .getActiveAdapterView().removeFooterView(mFooterSpacer);
+ .getActiveAdapterView().removeFooterView(mFooterSpacer);
}
mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT,
- mSystemWindowInsets.bottom));
+ mSystemWindowInsets.bottom));
((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .getActiveAdapterView().addFooterView(mFooterSpacer);
+ .getActiveAdapterView().addFooterView(mFooterSpacer);
}
protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
@@ -594,7 +499,7 @@ public class ResolverActivity extends FragmentActivity implements
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
- if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault()
+ if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault()
&& !shouldUseMiniResolver()) {
updateIntentPickerPaddings();
}
@@ -609,52 +514,7 @@ public class ResolverActivity extends FragmentActivity implements
return R.layout.resolver_list;
}
- @Override
- protected void onStop() {
- super.onStop();
-
- final Window window = this.getWindow();
- final WindowManager.LayoutParams attrs = window.getAttributes();
- attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
- window.setAttributes(attrs);
-
- if (mRegistered) {
- mPersonalPackageMonitor.unregister();
- if (mWorkPackageMonitor != null) {
- mWorkPackageMonitor.unregister();
- }
- mRegistered = false;
- }
- final Intent intent = getIntent();
- if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
- && !mResolvingHome && !mRetainInOnStop) {
- // This resolver is in the unusual situation where it has been
- // launched at the top of a new task. We don't let it be added
- // to the recent tasks shown to the user, and we need to make sure
- // that each time we are launched we get the correct launching
- // uid (not re-using the same resolver from an old launching uid),
- // so we will now finish ourself since being no longer visible,
- // the user probably can't get back to us.
- if (!isChangingConfigurations()) {
- finish();
- }
- }
- // TODO: should we clean up the work-profile manager before we potentially finish() above?
- mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this);
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- if (!isChangingConfigurations() && mPickOptionRequest != null) {
- mPickOptionRequest.cancel();
- }
- if (mMultiProfilePagerAdapter != null
- && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {
- mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();
- }
- }
-
+ // referenced by layout XML: android:onClick="onButtonClick"
public void onButtonClick(View v) {
final int id = v.getId();
ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView();
@@ -673,9 +533,9 @@ public class ResolverActivity extends FragmentActivity implements
ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()
.resolveInfoForPosition(which, hasIndexBeenFiltered);
if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) {
+ String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString();
Toast.makeText(this,
- getWorkProfileNotSupportedMsg(
- ri.activityInfo.loadLabel(getPackageManager()).toString()),
+ mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName),
Toast.LENGTH_LONG).show();
return;
}
@@ -686,15 +546,12 @@ public class ResolverActivity extends FragmentActivity implements
return;
}
if (onTargetSelected(target, always)) {
- if (always && mSupportsAlwaysUseOption) {
+ if (always) {
MetricsLogger.action(
this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS);
- } else if (mSupportsAlwaysUseOption) {
- MetricsLogger.action(
- this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE);
} else {
MetricsLogger.action(
- this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP);
+ this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE);
}
MetricsLogger.action(this,
mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
@@ -704,9 +561,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- /**
- * Replace me in subclasses!
- */
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
return defIntent;
@@ -715,7 +569,7 @@ public class ResolverActivity extends FragmentActivity implements
protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) {
final ItemClickListener listener = new ItemClickListener();
setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener);
- if (shouldShowTabs() && mIsIntentPicker) {
+ if (mProfiles.getWorkProfilePresent()) {
final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel);
if (rdl != null) {
rdl.setMaxCollapsedHeight(getResources()
@@ -730,9 +584,9 @@ public class ResolverActivity extends FragmentActivity implements
final ResolveInfo ri = target.getResolveInfo();
final Intent intent = target != null ? target.getResolvedIntent() : null;
- if (intent != null && (mSupportsAlwaysUseOption
- || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem())
- && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) {
+ if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/
+ && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList()
+ != null) {
// Build a reasonable intent filter, based on what matched.
IntentFilter filter = new IntentFilter();
Intent filterIntent;
@@ -774,7 +628,7 @@ public class ResolverActivity extends FragmentActivity implements
// or "content:" schemes (see IntentFilter for the reason).
if (cat != IntentFilter.MATCH_CATEGORY_TYPE
|| (!"file".equals(data.getScheme())
- && !"content".equals(data.getScheme()))) {
+ && !"content".equals(data.getScheme()))) {
filter.addDataScheme(data.getScheme());
// Look through the resolved filter to determine which part
@@ -832,7 +686,7 @@ public class ResolverActivity extends FragmentActivity implements
}
int bestMatch = 0;
- for (int i=0; i<N; i++) {
+ for (int i = 0; i < N; i++) {
ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()
.getUnfilteredResolveList().get(i).getResolveInfoAt(0);
set[i] = new ComponentName(r.activityInfo.packageName,
@@ -850,7 +704,7 @@ public class ResolverActivity extends FragmentActivity implements
if (always) {
final int userId = getUserId();
- final PackageManager pm = getPackageManager();
+ final PackageManager pm = mPackageManager;
// Set the preferred Activity
pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent());
@@ -859,7 +713,8 @@ public class ResolverActivity extends FragmentActivity implements
// Set default Browser if needed
final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId);
if (TextUtils.isEmpty(packageName)) {
- pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId);
+ pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName,
+ userId);
}
}
} else {
@@ -873,21 +728,11 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- if (target != null) {
- safelyStartActivity(target);
-
- // Rely on the ActivityManager to pop up a dialog regarding app suspension
- // and return false
- if (target.isSuspended()) {
- return false;
- }
- }
-
- return true;
- }
+ safelyStartActivity(target);
- public void onActivityStarted(TargetInfo cti) {
- // Do nothing
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ return !target.isSuspended();
}
@Override // ResolverListCommunicator
@@ -899,58 +744,65 @@ public class ResolverActivity extends FragmentActivity implements
return !target.isSuspended();
}
- // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses
- // that data to set up other components as dependencies of the controller. In reality, these
- // methods don't require polymorphism, because they're only invoked from within their respective
- // concrete class; `ResolverActivity` will never call this method expecting to get a
- // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this
- // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in
- // `ChooserActivity`. A future refactoring could better express the coupling between the adapter
- // and controller types; in the meantime, structuring as an override (with matching signatures)
- // shows that these methods are *structurally* related, and helps to prevent any regressions in
- // the future if resolver *were* to make any (non-overridden) calls to a version that used a
- // different signature (and thus didn't return the subclass type).
@VisibleForTesting
protected ResolverListController createListController(UserHandle userHandle) {
ResolverRankerServiceResolverComparator resolverComparator =
new ResolverRankerServiceResolverComparator(
this,
- getTargetIntent(),
- getReferrerPackageName(),
+ mRequest.getIntent(),
+ mViewModel.getActivityModel().getReferrerPackage(),
null,
null,
getResolverRankerServiceUserHandleList(userHandle),
null);
return new ResolverListController(
this,
- mPm,
- getTargetIntent(),
- getReferrerPackageName(),
- getAnnotatedUserHandles().userIdOfCallingApp,
+ mPackageManager,
+ mRequest.getIntent(),
+ mViewModel.getActivityModel().getReferrerPackage(),
+ mViewModel.getActivityModel().getLaunchedFromUid(),
resolverComparator,
- getQueryIntentsUser(userHandle));
+ mProfiles.getQueryIntentsHandle(userHandle));
}
/**
* Finishing procedures to be performed after the list has been rebuilt.
* </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
- * @param rebuildCompleted
+ *
* @return <code>true</code> if the activity is finishing and creation should halt.
*/
protected boolean postRebuildList(boolean rebuildCompleted) {
return postRebuildListInternal(rebuildCompleted);
}
- void onHorizontalSwipeStateChanged(int state) {}
-
/**
* Callback called when user changes the profile tab.
- * <p>This method is intended to be overridden by subclasses.
*/
- protected void onProfileTabSelected() { }
+ /* TODO: consider merging with the customized considerations of our implemented
+ * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions
+ * between the respective listener callbacks would occur in the triggering patterns during init
+ * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly
+ * if there's some way to trigger an update in one model but not the other. If there's an
+ * initialization dependency, we can probably reason about it with confidence. If there's a
+ * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is
+ * likely to be a bug that would benefit from consolidation.
+ */
+ protected void onProfileTabSelected(int currentPage) {
+ setupViewVisibilities();
+ maybeLogProfileChange();
+ if (mProfiles.getWorkProfilePresent()) {
+ // The device policy logger is only concerned with sessions that include a work profile.
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
+ .setInt(currentPage)
+ .setStrings(getMetricsCategory())
+ .write();
+ }
+ }
/**
* Add a label to signify that the user can pick a different app.
+ *
* @param adapter The adapter used to provide data to item views.
*/
public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) {
@@ -960,7 +812,7 @@ public class ResolverActivity extends FragmentActivity implements
stub.setVisibility(View.VISIBLE);
TextView textView = (TextView) LayoutInflater.from(this).inflate(
R.layout.resolver_different_item_header, null, false);
- if (shouldShowTabs()) {
+ if (mProfiles.getWorkProfilePresent()) {
textView.setGravity(Gravity.CENTER);
}
stub.addView(textView);
@@ -968,9 +820,6 @@ public class ResolverActivity extends FragmentActivity implements
}
protected void resetButtonBar() {
- if (!mSupportsAlwaysUseOption) {
- return;
- }
final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar);
if (buttonLayout == null) {
Log.e(TAG, "Layout unexpectedly does not have a button bar");
@@ -1012,56 +861,24 @@ public class ResolverActivity extends FragmentActivity implements
}
@Override // ResolverListCommunicator
- public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
- if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
- if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
- && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
- // We have just turned on the work profile and entered the pass code to start it,
- // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no
- // point in reloading the list now, since the work profile user is still
- // turning on.
- return;
- }
- boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true);
- if (listRebuilt) {
- ResolverListAdapter activeListAdapter =
- mMultiProfilePagerAdapter.getActiveListAdapter();
- activeListAdapter.notifyDataSetChanged();
- if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) {
- // We no longer have any items... just finish the activity.
- finish();
- }
- }
- } else {
- mMultiProfilePagerAdapter.clearInactiveProfileCache();
+ public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
+ if (!mMultiProfilePagerAdapter.onHandlePackagesChanged(
+ listAdapter,
+ mProfileAvailability.getWaitingToEnableProfile())) {
+ // We no longer have any items... just finish the activity.
+ finish();
}
}
protected void maybeLogProfileChange() {}
- // @NonFinalForTesting
- @VisibleForTesting
- protected MyUserIdProvider createMyUserIdProvider() {
- return new MyUserIdProvider();
- }
-
- // @NonFinalForTesting
@VisibleForTesting
protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
return new CrossProfileIntentsChecker(getContentResolver());
}
- protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
- final UserHandle workUser = getWorkProfileUserHandle();
-
- return new WorkProfileAvailabilityManager(
- getSystemService(UserManager.class),
- workUser,
- this::onWorkProfileStatusUpdated);
- }
-
- protected void onWorkProfileStatusUpdated() {
- if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getWorkProfileUserHandle())) {
+ private void onWorkProfileStatusUpdated() {
+ if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) {
mMultiProfilePagerAdapter.rebuildActiveTab(true);
} else {
mMultiProfilePagerAdapter.clearInactiveProfileCache();
@@ -1076,11 +893,8 @@ public class ResolverActivity extends FragmentActivity implements
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
- UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile()
- && userHandle.equals(getPersonalProfileUserHandle())
- ? getCloneProfileUserHandle() : userHandle;
+ UserHandle userHandle) {
+ UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle);
return new ResolverListAdapter(
context,
payloadIntents,
@@ -1089,33 +903,10 @@ public class ResolverActivity extends FragmentActivity implements
filterLastUsed,
createListController(userHandle),
userHandle,
- getTargetIntent(),
+ mRequest.getIntent(),
this,
initialIntentsUserSpace,
- targetDataLoader);
- }
-
- private TargetDataLoader createIconLoader() {
- Intent startIntent = getIntent();
- boolean isAudioCaptureDevice =
- startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
- return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice);
- }
-
- private LatencyTracker getLatencyTracker() {
- return LatencyTracker.getInstance(this);
- }
-
- /**
- * Get the string resource to be used as a label for the link to the resolver activity for an
- * action.
- *
- * @param action The action to resolve
- *
- * @return The string resource to be used as a label
- */
- public static @StringRes int getLabelRes(String action) {
- return ActionTitle.forAction(action).labelRes;
+ mTargetDataLoader);
}
protected final EmptyStateProvider createEmptyStateProvider(
@@ -1123,8 +914,10 @@ public class ResolverActivity extends FragmentActivity implements
final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
final EmptyStateProvider workProfileOffEmptyStateProvider =
- new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
- mWorkProfileAvailability,
+ new WorkProfilePausedEmptyStateProvider(
+ this,
+ mProfiles,
+ mProfileAvailability,
/* onSwitchOnWorkSelectedListener= */
() -> {
if (mOnSwitchOnWorkSelectedListener != null) {
@@ -1133,12 +926,11 @@ public class ResolverActivity extends FragmentActivity implements
},
getMetricsCategory());
- final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
- this,
- workProfileUserHandle,
- getPersonalProfileUserHandle(),
+ EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
+ mProfiles,
+ mProfileAvailability,
getMetricsCategory(),
- getTabOwnerUserHandleForLaunch()
+ mProfilePagerResources
);
// Return composite provider, the order matters (the higher, the more priority)
@@ -1149,76 +941,52 @@ public class ResolverActivity extends FragmentActivity implements
);
}
- private Intent makeMyIntent() {
- Intent intent = new Intent(getIntent());
- intent.setComponent(null);
- // The resolver activity is set to be hidden from recent tasks.
- // we don't want this attribute to be propagated to the next activity
- // being launched. Note that if the original Intent also had this
- // flag set, we are now losing it. That should be a very rare case
- // and we can live with this.
- intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
-
- // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate
- // side, which means we want to open the target app on the same side as ResolverActivity.
- if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) {
- intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT);
- }
- return intent;
- }
-
- /**
- * Call {@link Activity#onCreate} without initializing anything further. This should
- * only be used when the activity is about to be immediately finished to avoid wasting
- * initializing steps and leaking resources.
- */
- protected final void super_onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- }
-
- private ResolverMultiProfilePagerAdapter
- createResolverMultiProfilePagerAdapterForOneProfile(
- Intent[] initialIntents,
- List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- ResolverListAdapter adapter = createResolverListAdapter(
+ private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile(
+ Intent[] initialIntents,
+ List<ResolveInfo> resolutionList,
+ boolean filterLastUsed) {
+ ResolverListAdapter personalAdapter = createResolverListAdapter(
/* context */ this,
- /* payloadIntents */ mIntents,
+ mRequest.getPayloadIntents(),
initialIntents,
resolutionList,
filterLastUsed,
- /* userHandle */ getPersonalProfileUserHandle(),
- targetDataLoader);
+ /* userHandle */ mProfiles.getPersonalHandle()
+ );
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
- adapter,
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ personalAdapter)),
createEmptyStateProvider(/* workProfileUserHandle= */ null),
/* workProfileQuietModeChecker= */ () -> false,
+ /* defaultProfile= */ PROFILE_PERSONAL,
/* workProfileUserHandle= */ null,
- getCloneProfileUserHandle());
+ mProfiles.getCloneHandle());
}
private UserHandle getIntentUser() {
- return getIntent().hasExtra(EXTRA_CALLING_USER)
- ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
- : getTabOwnerUserHandleForLaunch();
+ return Objects.requireNonNullElse(mRequest.getCallingUser(),
+ mProfiles.getTabOwnerUserHandleForLaunch());
}
private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles(
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
+ boolean filterLastUsed) {
// In the edge case when we have 0 apps in the current profile and >1 apps in the other,
// the intent resolver is started in the other profile. Since this is the only case when
// this happens, we check for it here and set the current profile's tab.
int selectedProfile = getCurrentProfile();
UserHandle intentUser = getIntentUser();
- if (!getTabOwnerUserHandleForLaunch().equals(intentUser)) {
- if (getPersonalProfileUserHandle().equals(intentUser)) {
+ if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) {
+ if (mProfiles.getPersonalHandle().equals(intentUser)) {
selectedProfile = PROFILE_PERSONAL;
- } else if (getWorkProfileUserHandle().equals(intentUser)) {
+ } else if (mProfiles.getWorkHandle().equals(intentUser)) {
selectedProfile = PROFILE_WORK;
}
} else {
@@ -1232,119 +1000,68 @@ public class ResolverActivity extends FragmentActivity implements
// resolver list. So filterLastUsed should be false for the other profile.
ResolverListAdapter personalAdapter = createResolverListAdapter(
/* context */ this,
- /* payloadIntents */ mIntents,
+ mRequest.getPayloadIntents(),
selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
resolutionList,
(filterLastUsed && UserHandle.myUserId()
- == getPersonalProfileUserHandle().getIdentifier()),
- /* userHandle */ getPersonalProfileUserHandle(),
- targetDataLoader);
- UserHandle workProfileUserHandle = getWorkProfileUserHandle();
+ == mProfiles.getPersonalHandle().getIdentifier()),
+ /* userHandle */ mProfiles.getPersonalHandle()
+ );
+ UserHandle workProfileUserHandle = mProfiles.getWorkHandle();
ResolverListAdapter workAdapter = createResolverListAdapter(
/* context */ this,
- /* payloadIntents */ mIntents,
+ mRequest.getPayloadIntents(),
selectedProfile == PROFILE_WORK ? initialIntents : null,
resolutionList,
(filterLastUsed && UserHandle.myUserId()
== workProfileUserHandle.getIdentifier()),
- /* userHandle */ workProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ workProfileUserHandle
+ );
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
- personalAdapter,
- workAdapter,
- createEmptyStateProvider(getWorkProfileUserHandle()),
- () -> mWorkProfileAvailability.isQuietModeEnabled(),
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ personalAdapter),
+ new TabConfig<>(
+ PROFILE_WORK,
+ mDevicePolicyResources.getWorkTabLabel(),
+ mDevicePolicyResources.getWorkTabAccessibilityLabel(),
+ TAB_TAG_WORK,
+ workAdapter)),
+ createEmptyStateProvider(workProfileUserHandle),
+ /* Supplier<Boolean> (QuietMode enabled) == !(available) */
+ () -> !(mProfiles.getWorkProfilePresent()
+ && mProfileAvailability.isAvailable(
+ requireNonNull(mProfiles.getWorkProfile()))),
selectedProfile,
- getWorkProfileUserHandle(),
- getCloneProfileUserHandle());
+ workProfileUserHandle,
+ mProfiles.getCloneHandle());
}
/**
* Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link
* #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied.
- * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE}
- * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}
*/
final int getSelectedProfileExtra() {
- int selectedProfile = -1;
- if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) {
- selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1);
- if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) {
- throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value "
- + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or "
- + "ResolverActivity.PROFILE_WORK.");
- }
+ Profile.Type selected = mRequest.getSelectedProfile();
+ if (selected == null) {
+ return -1;
}
- return selectedProfile;
- }
-
- protected final @Profile int getCurrentProfile() {
- return (getTabOwnerUserHandleForLaunch().equals(getPersonalProfileUserHandle())
- ? PROFILE_PERSONAL : PROFILE_WORK);
- }
-
- protected final AnnotatedUserHandles getAnnotatedUserHandles() {
- return mLazyAnnotatedUserHandles.get();
- }
-
- protected final UserHandle getPersonalProfileUserHandle() {
- return getAnnotatedUserHandles().personalProfileUserHandle;
- }
-
- // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`.
- // @NonFinalForTesting
- @Nullable
- protected UserHandle getWorkProfileUserHandle() {
- return getAnnotatedUserHandles().workProfileUserHandle;
- }
-
- // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`.
- @Nullable
- protected UserHandle getCloneProfileUserHandle() {
- return getAnnotatedUserHandles().cloneProfileUserHandle;
- }
-
- // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`.
- protected UserHandle getTabOwnerUserHandleForLaunch() {
- return getAnnotatedUserHandles().tabOwnerUserHandleForLaunch;
- }
-
- protected UserHandle getUserHandleSharesheetLaunchedAs() {
- return getAnnotatedUserHandles().userHandleSharesheetLaunchedAs;
- }
-
-
- private boolean hasWorkProfile() {
- return getWorkProfileUserHandle() != null;
- }
-
- private boolean hasCloneProfile() {
- return getCloneProfileUserHandle() != null;
- }
-
- protected final boolean isLaunchedAsCloneProfile() {
- return hasCloneProfile()
- && getUserHandleSharesheetLaunchedAs().equals(getCloneProfileUserHandle());
- }
-
-
- protected final boolean shouldShowTabs() {
- return hasWorkProfile();
- }
-
- protected final void onProfileClick(View v) {
- final DisplayResolveInfo dri =
- mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile();
- if (dri == null) {
- return;
+ switch (selected) {
+ case PERSONAL: return PROFILE_PERSONAL;
+ case WORK: return PROFILE_WORK;
+ default: return -1;
}
+ }
- // Do not show the profile switch message anymore.
- mProfileSwitchMessage = null;
-
- onTargetSelected(dri, false);
- finish();
+ protected final @ProfileType int getCurrentProfile() {
+ UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch();
+ UserHandle personalUser = mProfiles.getPersonalHandle();
+ return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK;
}
private void updateIntentPickerPaddings() {
@@ -1363,12 +1080,15 @@ public class ResolverActivity extends FragmentActivity implements
}
private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
- if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
+ // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles
+ if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) {
return;
}
DevicePolicyEventLogger
.createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
- .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle()))
+ .setBoolean(
+ currentUserHandle.equals(
+ mProfiles.getPersonalHandle()))
.setStrings(getMetricsCategory(),
cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
.write();
@@ -1399,67 +1119,7 @@ public class ResolverActivity extends FragmentActivity implements
}
final Option optionForChooserTarget(TargetInfo target, int index) {
- return new Option(target.getDisplayLabel(), index);
- }
-
- public final Intent getTargetIntent() {
- return mIntents.isEmpty() ? null : mIntents.get(0);
- }
-
- protected final String getReferrerPackageName() {
- final Uri referrer = getReferrer();
- if (referrer != null && "android-app".equals(referrer.getScheme())) {
- return referrer.getHost();
- }
- return null;
- }
-
- @Override // ResolverListCommunicator
- public final void updateProfileViewButton() {
- if (mProfileView == null) {
- return;
- }
-
- final DisplayResolveInfo dri =
- mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile();
- if (dri != null && !shouldShowTabs()) {
- mProfileView.setVisibility(View.VISIBLE);
- View text = mProfileView.findViewById(com.android.internal.R.id.profile_button);
- if (!(text instanceof TextView)) {
- text = mProfileView.findViewById(com.android.internal.R.id.text1);
- }
- ((TextView) text).setText(dri.getDisplayLabel());
- } else {
- mProfileView.setVisibility(View.GONE);
- }
- }
-
- private void setProfileSwitchMessage(int contentUserHint) {
- if ((contentUserHint != UserHandle.USER_CURRENT)
- && (contentUserHint != UserHandle.myUserId())) {
- UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
- UserInfo originUserInfo = userManager.getUserInfo(contentUserHint);
- boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile()
- : false;
- boolean targetIsManaged = userManager.isManagedProfile();
- if (originIsManaged && !targetIsManaged) {
- mProfileSwitchMessage = getForwardToPersonalMsg();
- } else if (!originIsManaged && targetIsManaged) {
- mProfileSwitchMessage = getForwardToWorkMsg();
- }
- }
- }
-
- private String getForwardToPersonalMsg() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- FORWARD_INTENT_TO_PERSONAL,
- () -> getString(R.string.forward_intent_to_owner));
- }
-
- private String getForwardToWorkMsg() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- FORWARD_INTENT_TO_WORK,
- () -> getString(R.string.forward_intent_to_work));
+ return new Option(getOrLoadDisplayLabel(target), index);
}
protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
@@ -1475,73 +1135,15 @@ public class ResolverActivity extends FragmentActivity implements
return getString(defaultTitleRes);
} else {
return named
- ? getString(title.namedTitleRes, mMultiProfilePagerAdapter
- .getActiveListAdapter().getFilteredItem().getDisplayLabel())
+ ? getString(
+ title.namedTitleRes,
+ getOrLoadDisplayLabel(
+ mMultiProfilePagerAdapter
+ .getActiveListAdapter().getFilteredItem()))
: getString(title.titleRes);
}
}
- final void dismiss() {
- if (!isFinishing()) {
- finish();
- }
- }
-
- @Override
- protected final void onRestart() {
- super.onRestart();
- if (!mRegistered) {
- mPersonalPackageMonitor.register(this, getMainLooper(),
- getPersonalProfileUserHandle(), false);
- if (shouldShowTabs()) {
- if (mWorkPackageMonitor == null) {
- mWorkPackageMonitor = createPackageMonitor(
- mMultiProfilePagerAdapter.getWorkListAdapter());
- }
- mWorkPackageMonitor.register(this, getMainLooper(),
- getWorkProfileUserHandle(), false);
- }
- mRegistered = true;
- }
- if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
- if (mWorkProfileAvailability.isQuietModeEnabled()) {
- mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived();
- }
- }
- mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
- updateProfileViewButton();
- }
-
- @Override
- protected final void onStart() {
- super.onStart();
-
- this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
- if (shouldShowTabs()) {
- mWorkProfileAvailability.registerWorkProfileStateReceiver(this);
- }
- }
-
- @Override
- protected final void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
- if (viewPager != null) {
- outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem());
- }
- }
-
- @Override
- protected final void onRestoreInstanceState(Bundle savedInstanceState) {
- super.onRestoreInstanceState(savedInstanceState);
- resetButtonBar();
- ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
- if (viewPager != null) {
- viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
- }
- mMultiProfilePagerAdapter.clearInactiveProfileCache();
- }
-
private boolean hasManagedProfile() {
UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
if (userManager == null) {
@@ -1563,7 +1165,7 @@ public class ResolverActivity extends FragmentActivity implements
private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
try {
- ApplicationInfo appInfo = getPackageManager().getApplicationInfo(
+ ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
resolveInfo.activityInfo.packageName, 0 /* default flags */);
return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
} catch (NameNotFoundException e) {
@@ -1581,7 +1183,8 @@ public class ResolverActivity extends FragmentActivity implements
// In case of clonedProfile being active, we do not allow the 'Always' option in the
// disambiguation dialog of Personal Profile as the package manager cannot distinguish
// between cross-profile preferred activities.
- if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) {
+ if (mProfiles.getCloneUserPresent()
+ && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) {
mAlwaysButton.setEnabled(false);
return;
}
@@ -1607,41 +1210,28 @@ public class ResolverActivity extends FragmentActivity implements
if (ri != null) {
ActivityInfo activityInfo = ri.activityInfo;
- boolean hasRecordPermission =
- mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO,
+ boolean hasRecordPermission = mPackageManager
+ .checkPermission(android.Manifest.permission.RECORD_AUDIO,
activityInfo.packageName)
- == android.content.pm.PackageManager.PERMISSION_GRANTED;
+ == PackageManager.PERMISSION_GRANTED;
if (!hasRecordPermission) {
// OK, we know the record permission, is this a capture device
- boolean hasAudioCapture =
- getIntent().getBooleanExtra(
- ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
+ boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice();
enabled = !hasAudioCapture;
}
}
mAlwaysButton.setEnabled(enabled);
}
- private String getWorkProfileNotSupportedMsg(String launcherName) {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_WORK_PROFILE_NOT_SUPPORTED,
- () -> getString(
- R.string.activity_resolver_work_profiles_support,
- launcherName),
- launcherName);
- }
-
@Override // ResolverListCommunicator
public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
boolean rebuildCompleted) {
if (isAutolaunching()) {
return;
}
- if (mIsIntentPicker) {
- ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .setUseLayoutWithDefault(useLayoutWithDefault());
- }
+ mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault());
+
if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) {
mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter);
} else {
@@ -1690,45 +1280,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- @VisibleForTesting
- protected void safelyStartActivityInternal(
- TargetInfo cti, UserHandle user, @Nullable Bundle options) {
- // If the target is suspended, the activity will not be successfully launched.
- // Do not unregister from package manager updates in this case
- if (!cti.isSuspended() && mRegistered) {
- if (mPersonalPackageMonitor != null) {
- mPersonalPackageMonitor.unregister();
- }
- if (mWorkPackageMonitor != null) {
- mWorkPackageMonitor.unregister();
- }
- mRegistered = false;
- }
- // If needed, show that intent is forwarded
- // from managed profile to owner or other way around.
- if (mProfileSwitchMessage != null) {
- Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show();
- }
- if (!mSafeForwardingMode) {
- if (cti.startAsUser(this, options, user)) {
- onActivityStarted(cti);
- maybeLogCrossProfileTargetLaunch(cti, user);
- }
- return;
- }
- try {
- if (cti.startAsCaller(this, options, user.getIdentifier())) {
- onActivityStarted(cti);
- maybeLogCrossProfileTargetLaunch(cti, user);
- }
- } catch (RuntimeException e) {
- Slog.wtf(TAG,
- "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp
- + " package " + getLaunchedFromPackage() + ", while running in "
- + ActivityThread.currentProcessName(), e);
- }
- }
-
final void showTargetDetails(ResolveInfo ri) {
Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", ri.activityInfo.packageName, null))
@@ -1748,13 +1299,9 @@ public class ResolverActivity extends FragmentActivity implements
Trace.beginSection("configureContentView");
// We partially rebuild the inactive adapter to determine if we should auto launch
// isTabLoaded will be true here if the empty state screen is shown instead of the list.
- boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true)
- || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded();
- if (shouldShowTabs()) {
- boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false)
- || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded();
- rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted;
- }
+ // To date, we really only care about "partially rebuilding" tabs for work and/or personal.
+ boolean rebuildCompleted =
+ mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent());
if (shouldUseMiniResolver()) {
configureMiniResolverContent(targetDataLoader);
@@ -1768,7 +1315,8 @@ public class ResolverActivity extends FragmentActivity implements
mLayoutId = getLayoutResource();
}
setContentView(mLayoutId);
- mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager));
+ mMultiProfilePagerAdapter.setupViewPager(
+ findViewById(com.android.internal.R.id.profile_pager));
boolean result = postRebuildList(rebuildCompleted);
Trace.endSection();
return result;
@@ -1784,18 +1332,26 @@ public class ResolverActivity extends FragmentActivity implements
mLayoutId = R.layout.miniresolver;
setContentView(mLayoutId);
- DisplayResolveInfo sameProfileResolveInfo =
- mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo();
boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK;
- final ResolverListAdapter inactiveAdapter =
- mMultiProfilePagerAdapter.getInactiveListAdapter();
+ ResolverListAdapter sameProfileAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mMultiProfilePagerAdapter.getWorkListAdapter();
+
+ ResolverListAdapter inactiveAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getWorkListAdapter()
+ : mMultiProfilePagerAdapter.getPersonalListAdapter();
+
+ DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo();
+
final DisplayResolveInfo otherProfileResolveInfo =
inactiveAdapter.getFirstDisplayResolveInfo();
// Load the icon asynchronously
ImageView icon = findViewById(com.android.internal.R.id.icon);
- targetDataLoader.loadAppTargetIcon(
+ targetDataLoader.getOrLoadAppTargetIcon(
otherProfileResolveInfo,
inactiveAdapter.getUserHandle(),
(drawable) -> {
@@ -1807,9 +1363,10 @@ public class ResolverActivity extends FragmentActivity implements
((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText(
getResources().getString(
- inWorkProfile ? R.string.miniresolver_open_in_personal
+ inWorkProfile
+ ? R.string.miniresolver_open_in_personal
: R.string.miniresolver_open_in_work,
- otherProfileResolveInfo.getDisplayLabel()));
+ getOrLoadDisplayLabel(otherProfileResolveInfo)));
((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText(
inWorkProfile ? R.string.miniresolver_use_work_browser
: R.string.miniresolver_use_personal_browser);
@@ -1827,6 +1384,69 @@ public class ResolverActivity extends FragmentActivity implements
});
}
+ private boolean isTwoPagePersonalAndWorkConfiguration() {
+ return (mMultiProfilePagerAdapter.getCount() == 2)
+ && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL)
+ && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK);
+ }
+
+ @VisibleForTesting
+ protected void safelyStartActivityInternal(
+ TargetInfo cti, UserHandle user, @Nullable Bundle options) {
+ // If the target is suspended, the activity will not be successfully launched.
+ // Do not unregister from package manager updates in this case
+ if (!cti.isSuspended() && mRegistered) {
+ if (mPersonalPackageMonitor != null) {
+ mPersonalPackageMonitor.unregister();
+ }
+ if (mWorkPackageMonitor != null) {
+ mWorkPackageMonitor.unregister();
+ }
+ mRegistered = false;
+ }
+ // If needed, show that intent is forwarded
+ // from managed profile to owner or other way around.
+ String profileSwitchMessage =
+ mIntentForwarding.forwardMessageFor(mRequest.getIntent());
+ if (profileSwitchMessage != null) {
+ Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show();
+ }
+ try {
+ if (cti.startAsCaller(this, options, user.getIdentifier())) {
+ maybeLogCrossProfileTargetLaunch(cti, user);
+ }
+ } catch (RuntimeException e) {
+ Slog.wtf(TAG,
+ "Unable to launch as uid "
+ + mViewModel.getActivityModel().getLaunchedFromUid()
+ + " package " + mViewModel.getActivityModel().getLaunchedFromPackage()
+ + ", while running in " + ActivityThread.currentProcessName(), e);
+ }
+ }
+
+ /**
+ * Finishing procedures to be performed after the list has been rebuilt.
+ * @param rebuildCompleted
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ final boolean postRebuildListInternal(boolean rebuildCompleted) {
+ int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
+
+ // We only rebuild asynchronously when we have multiple elements to sort. In the case where
+ // we're already done, we can check if we should auto-launch immediately.
+ if (rebuildCompleted && maybeAutolaunchActivity()) {
+ return true;
+ }
+
+ setupViewVisibilities();
+
+ if (mProfiles.getWorkProfilePresent()) {
+ setupProfileTabs();
+ }
+
+ return false;
+ }
+
/**
* Mini resolver should be used when all of the following are true:
* 1. This is the intent picker (ResolverActivity).
@@ -1834,17 +1454,19 @@ public class ResolverActivity extends FragmentActivity implements
* 3. The other profile has a single non-browser match.
*/
private boolean shouldUseMiniResolver() {
- if (!mIsIntentPicker) {
- return false;
- }
- if (mMultiProfilePagerAdapter.getActiveListAdapter() == null
- || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
return false;
}
+
ResolverListAdapter sameProfileAdapter =
- mMultiProfilePagerAdapter.getActiveListAdapter();
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mMultiProfilePagerAdapter.getWorkListAdapter();
+
ResolverListAdapter otherProfileAdapter =
- mMultiProfilePagerAdapter.getInactiveListAdapter();
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getWorkListAdapter()
+ : mMultiProfilePagerAdapter.getPersonalListAdapter();
if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) {
Log.d(TAG, "No targets in the current profile");
@@ -1869,53 +1491,6 @@ public class ResolverActivity extends FragmentActivity implements
return true;
}
- /**
- * Finishing procedures to be performed after the list has been rebuilt.
- * @param rebuildCompleted
- * @return <code>true</code> if the activity is finishing and creation should halt.
- */
- final boolean postRebuildListInternal(boolean rebuildCompleted) {
- int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
-
- // We only rebuild asynchronously when we have multiple elements to sort. In the case where
- // we're already done, we can check if we should auto-launch immediately.
- if (rebuildCompleted && maybeAutolaunchActivity()) {
- return true;
- }
-
- setupViewVisibilities();
-
- if (shouldShowTabs()) {
- setupProfileTabs();
- }
-
- return false;
- }
-
- private int isPermissionGranted(String permission, int uid) {
- return ActivityManager.checkComponentPermission(permission, uid,
- /* owningUid= */-1, /* exported= */ true);
- }
-
- /**
- * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
- */
- private boolean maybeAutolaunchActivity() {
- int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount();
- if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) {
- return true;
- } else if (numberOfProfiles == 2
- && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded()
- && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded()
- && maybeAutolaunchIfCrossProfileSupported()) {
- // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the
- // correct intent-picker UIs (e.g., mini-resolver) if it was launched without
- // ACTION_SEND.
- return true;
- }
- return false;
- }
-
private boolean maybeAutolaunchIfSingleTarget() {
int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
if (count != 1) {
@@ -1938,42 +1513,57 @@ public class ResolverActivity extends FragmentActivity implements
}
/**
- * When we have a personal and a work profile, we auto launch in the following scenario:
+ * When we have just a personal and a work profile, we auto launch in the following scenario:
* - There is 1 resolved target on each profile
* - That target is the same app on both profiles
* - The target app has permission to communicate cross profiles
* - The target app has declared it supports cross-profile communication via manifest metadata
*/
private boolean maybeAutolaunchIfCrossProfileSupported() {
- ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();
- int count = activeListAdapter.getUnfilteredCount();
- if (count != 1) {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
return false;
}
+
+ ResolverListAdapter activeListAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mMultiProfilePagerAdapter.getWorkListAdapter();
+
ResolverListAdapter inactiveListAdapter =
- mMultiProfilePagerAdapter.getInactiveListAdapter();
- if (inactiveListAdapter.getUnfilteredCount() != 1) {
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getWorkListAdapter()
+ : mMultiProfilePagerAdapter.getPersonalListAdapter();
+
+ if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) {
return false;
}
- TargetInfo activeProfileTarget = activeListAdapter
- .targetInfoForPosition(0, false);
+
+ if ((activeListAdapter.getUnfilteredCount() != 1)
+ || (inactiveListAdapter.getUnfilteredCount() != 1)) {
+ return false;
+ }
+
+ TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false);
TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false);
- if (!Objects.equals(activeProfileTarget.getResolvedComponentName(),
+ if (!Objects.equals(
+ activeProfileTarget.getResolvedComponentName(),
inactiveProfileTarget.getResolvedComponentName())) {
return false;
}
+
if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) {
return false;
}
+
String packageName = activeProfileTarget.getResolvedComponentName().getPackageName();
- if (!canAppInteractCrossProfiles(packageName)) {
+ if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) {
return false;
}
DevicePolicyEventLogger
.createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET)
.setBoolean(activeListAdapter.getUserHandle()
- .equals(getPersonalProfileUserHandle()))
+ .equals(mProfiles.getPersonalHandle()))
.setStrings(getMetricsCategory())
.write();
safelyStartActivity(activeProfileTarget);
@@ -1981,140 +1571,66 @@ public class ResolverActivity extends FragmentActivity implements
return true;
}
+ private boolean isAutolaunching() {
+ return !mRegistered && isFinishing();
+ }
+
/**
- * Returns whether the package has the necessary permissions to interact across profiles on
- * behalf of a given user.
- *
- * <p>This means meeting the following condition:
- * <ul>
- * <li>The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least
- * one of the following conditions must be fulfilled</li>
- * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.</li>
- * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS} granted.</li>
- * <li>{@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding
- * AppOps {@code android:interact_across_profiles} is set to "allow".</li>
- * </ul>
- *
+ * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
*/
- private boolean canAppInteractCrossProfiles(String packageName) {
- ApplicationInfo applicationInfo;
- try {
- applicationInfo = getPackageManager().getApplicationInfo(packageName, 0);
- } catch (NameNotFoundException e) {
- Log.e(TAG, "Package " + packageName + " does not exist on current user.");
- return false;
- }
- if (!applicationInfo.crossProfile) {
+ private boolean maybeAutolaunchActivity() {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
return false;
}
- int packageUid = applicationInfo.uid;
+ ResolverListAdapter activeListAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mMultiProfilePagerAdapter.getWorkListAdapter();
- if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL,
- packageUid) == PackageManager.PERMISSION_GRANTED) {
- return true;
- }
- if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid)
- == PackageManager.PERMISSION_GRANTED) {
- return true;
- }
- if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES,
- PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) {
- return true;
- }
- return false;
- }
+ ResolverListAdapter inactiveListAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getWorkListAdapter()
+ : mMultiProfilePagerAdapter.getPersonalListAdapter();
- private boolean isAutolaunching() {
- return !mRegistered && isFinishing();
- }
+ if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) {
+ return false;
+ }
- private void setupProfileTabs() {
- maybeHideDivider();
- TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost);
- tabHost.setup();
- ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
- viewPager.setSaveEnabled(false);
-
- Button personalButton = (Button) getLayoutInflater().inflate(
- R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false);
- personalButton.setText(getPersonalTabLabel());
- personalButton.setContentDescription(getPersonalTabAccessibilityLabel());
-
- TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL)
- .setContent(com.android.internal.R.id.profile_pager)
- .setIndicator(personalButton);
- tabHost.addTab(tabSpec);
-
- Button workButton = (Button) getLayoutInflater().inflate(
- R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false);
- workButton.setText(getWorkTabLabel());
- workButton.setContentDescription(getWorkTabAccessibilityLabel());
-
- tabSpec = tabHost.newTabSpec(TAB_TAG_WORK)
- .setContent(com.android.internal.R.id.profile_pager)
- .setIndicator(workButton);
- tabHost.addTab(tabSpec);
-
- TabWidget tabWidget = tabHost.getTabWidget();
- tabWidget.setVisibility(View.VISIBLE);
- updateActiveTabStyle(tabHost);
-
- tabHost.setOnTabChangedListener(tabId -> {
- updateActiveTabStyle(tabHost);
- if (TAB_TAG_PERSONAL.equals(tabId)) {
- viewPager.setCurrentItem(0);
- } else {
- viewPager.setCurrentItem(1);
- }
- setupViewVisibilities();
- maybeLogProfileChange();
- onProfileTabSelected();
- DevicePolicyEventLogger
- .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
- .setInt(viewPager.getCurrentItem())
- .setStrings(getMetricsCategory())
- .write();
- });
+ if ((activeListAdapter.getUnfilteredCount() != 1)
+ || (inactiveListAdapter.getUnfilteredCount() != 1)) {
+ return false;
+ }
- viewPager.setVisibility(View.VISIBLE);
- tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage());
- mMultiProfilePagerAdapter.setOnProfileSelectedListener(
- new AbstractMultiProfilePagerAdapter.OnProfileSelectedListener() {
- @Override
- public void onProfileSelected(int index) {
- tabHost.setCurrentTab(index);
- resetButtonBar();
- resetCheckedItem();
- }
+ TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false);
+ TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false);
+ if (!Objects.equals(
+ activeProfileTarget.getResolvedComponentName(),
+ inactiveProfileTarget.getResolvedComponentName())) {
+ return false;
+ }
- @Override
- public void onProfilePageStateChanged(int state) {
- onHorizontalSwipeStateChanged(state);
- }
- });
- mOnSwitchOnWorkSelectedListener = () -> {
- final View workTab = tabHost.getTabWidget().getChildAt(1);
- workTab.setFocusable(true);
- workTab.setFocusableInTouchMode(true);
- workTab.requestFocus();
- };
- }
+ if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) {
+ return false;
+ }
- private String getPersonalTabLabel() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab));
- }
+ String packageName = activeProfileTarget.getResolvedComponentName().getPackageName();
+ if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) {
+ return false;
+ }
- private String getWorkTabLabel() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab));
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET)
+ .setBoolean(activeListAdapter.getUserHandle()
+ .equals(mProfiles.getPersonalHandle()))
+ .setStrings(getMetricsCategory())
+ .write();
+ safelyStartActivity(activeProfileTarget);
+ finish();
+ return true;
}
private void maybeHideDivider() {
- if (!mIsIntentPicker) {
- return;
- }
final View divider = findViewById(com.android.internal.R.id.divider);
if (divider == null) {
return;
@@ -2123,41 +1639,9 @@ public class ResolverActivity extends FragmentActivity implements
}
private void resetCheckedItem() {
- if (!mIsIntentPicker) {
- return;
- }
mLastSelected = ListView.INVALID_POSITION;
- ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView();
- if (inactiveListView.getCheckedItemCount() > 0) {
- inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false);
- }
- }
-
- private String getPersonalTabAccessibilityLabel() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_PERSONAL_TAB_ACCESSIBILITY,
- () -> getString(R.string.resolver_personal_tab_accessibility));
- }
-
- private String getWorkTabAccessibilityLabel() {
- return getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_WORK_TAB_ACCESSIBILITY,
- () -> getString(R.string.resolver_work_tab_accessibility));
- }
-
- private static int getAttrColor(Context context, int attr) {
- TypedArray ta = context.obtainStyledAttributes(new int[]{attr});
- int colorAccent = ta.getColor(0, 0);
- ta.recycle();
- return colorAccent;
- }
-
- private void updateActiveTabStyle(TabHost tabHost) {
- int currentTab = tabHost.getCurrentTab();
- TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab);
- TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab);
- selected.setSelected(true);
- unselected.setSelected(false);
+ ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
+ .clearCheckedItemsInInactiveProfiles();
}
private void setupViewVisibilities() {
@@ -2185,10 +1669,7 @@ public class ResolverActivity extends FragmentActivity implements
private void setupAdapterListView(ListView listView, ItemClickListener listener) {
listView.setOnItemClickListener(listener);
listView.setOnItemLongClickListener(listener);
-
- if (mSupportsAlwaysUseOption) {
- listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
- }
+ listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
}
/**
@@ -2199,17 +1680,17 @@ public class ResolverActivity extends FragmentActivity implements
&& !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) {
return;
}
- if (!shouldShowTabs()
+ if (!mProfiles.getWorkProfilePresent()
&& listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) {
final TextView titleView = findViewById(com.android.internal.R.id.title);
if (titleView != null) {
titleView.setVisibility(View.GONE);
}
}
-
- CharSequence title = mTitle != null
- ? mTitle
- : getTitleForAction(getTargetIntent(), mDefaultTitleResId);
+ ResolverRequest request = mViewModel.getRequest().getValue();
+ CharSequence title = mViewModel.getRequest().getValue().getTitle() != null
+ ? request.getTitle()
+ : getTitleForAction(request.getIntent(), 0);
if (!TextUtils.isEmpty(title)) {
final TextView titleView = findViewById(com.android.internal.R.id.title);
@@ -2254,39 +1735,9 @@ public class ResolverActivity extends FragmentActivity implements
public final boolean useLayoutWithDefault() {
// We only use the default app layout when the profile of the active user has a
// filtered item. We always show the same default app even in the inactive user profile.
- boolean adapterForCurrentUserHasFilteredItem =
- mMultiProfilePagerAdapter.getListAdapterForUserHandle(
- getTabOwnerUserHandleForLaunch()).hasFilteredItem();
- return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem;
- }
-
- /**
- * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets
- * called and we are launched in a new task.
- */
- protected final void setRetainInOnStop(boolean retainInOnStop) {
- mRetainInOnStop = retainInOnStop;
- }
-
- /**
- * Check a simple match for the component of two ResolveInfos.
- */
- @Override // ResolverListCommunicator
- public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) {
- return lhs == null ? rhs == null
- : lhs.activityInfo == null ? rhs.activityInfo == null
- : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name)
- && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName)
- // Comparing against resolveInfo.userHandle in case cloned apps are present,
- // as they will have the same activityInfo.
- && Objects.equals(lhs.userHandle, rhs.userHandle);
- }
-
- private boolean inactiveListAdapterHasItems() {
- if (!shouldShowTabs()) {
- return false;
- }
- return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0;
+ return mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ mProfiles.getTabOwnerUserHandleForLaunch()
+ ).hasFilteredItem();
}
final class ItemClickListener implements AdapterView.OnItemClickListener,
@@ -2343,11 +1794,37 @@ public class ResolverActivity extends FragmentActivity implements
}
- /** Determine whether a given match result is considered "specific" in our application. */
- public static final boolean isSpecificUriMatch(int match) {
- match = (match & IntentFilter.MATCH_CATEGORY_MASK);
- return match >= IntentFilter.MATCH_CATEGORY_HOST
- && match <= IntentFilter.MATCH_CATEGORY_PATH;
+ private void setupProfileTabs() {
+ maybeHideDivider();
+
+ TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost);
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+
+ mMultiProfilePagerAdapter.setupProfileTabs(
+ getLayoutInflater(),
+ tabHost,
+ viewPager,
+ R.layout.resolver_profile_tab_button,
+ com.android.internal.R.id.profile_pager,
+ () -> onProfileTabSelected(viewPager.getCurrentItem()),
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {
+ resetButtonBar();
+ resetCheckedItem();
+ }
+
+ @Override
+ public void onProfilePageStateChanged(int state) {}
+ });
+ mOnSwitchOnWorkSelectedListener = () -> {
+ final View workTab =
+ tabHost.getTabWidget().getChildAt(
+ mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK));
+ workTab.setFocusable(true);
+ workTab.setFocusableInTouchMode(true);
+ workTab.requestFocus();
+ };
}
static final class PickTargetOptionRequest extends PickOptionRequest {
@@ -2391,7 +1868,7 @@ public class ResolverActivity extends FragmentActivity implements
* {@link ResolverListController} configured for the provided {@code userHandle}.
*/
protected final UserHandle getQueryIntentsUser(UserHandle userHandle) {
- return mLazyAnnotatedUserHandles.get().getQueryIntentsUser(userHandle);
+ return mProfiles.getQueryIntentsHandle(userHandle);
}
/**
@@ -2411,10 +1888,18 @@ public class ResolverActivity extends FragmentActivity implements
// Add clonedProfileUserHandle to the list only if we are:
// a. Building the Personal Tab.
// b. CloneProfile exists on the device.
- if (userHandle.equals(getPersonalProfileUserHandle())
- && getCloneProfileUserHandle() != null) {
- userList.add(getCloneProfileUserHandle());
+ if (userHandle.equals(mProfiles.getPersonalHandle())
+ && mProfiles.getCloneUserPresent()) {
+ userList.add(mProfiles.getCloneHandle());
}
return userList;
}
+
+ private CharSequence getOrLoadDisplayLabel(TargetInfo info) {
+ if (info.isDisplayResolveInfo()) {
+ mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info);
+ }
+ CharSequence displayLabel = info.getDisplayLabel();
+ return displayLabel == null ? "" : displayLabel;
+ }
}
diff --git a/java/src/com/android/intentresolver/ResolverHelper.kt b/java/src/com/android/intentresolver/ResolverHelper.kt
new file mode 100644
index 00000000..d12ba7d5
--- /dev/null
+++ b/java/src/com/android/intentresolver/ResolverHelper.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.app.Activity
+import android.os.UserHandle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.viewModels
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.ui.model.ResolverRequest
+import com.android.intentresolver.ui.viewmodel.ResolverViewModel
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.log
+import dagger.hilt.android.scopes.ActivityScoped
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+
+private const val TAG: String = "ResolverHelper"
+
+/**
+ * __Purpose__
+ *
+ * Cleanup aid. Provides a pathway to cleaner code.
+ *
+ * __Incoming References__
+ *
+ * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If
+ * a value or operation is required by ResolverActivity, then it must be added to
+ * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a
+ * callback to receive it at the appropriate point. This enforces unidirectional control flow.
+ *
+ * __Outgoing References__
+ *
+ * _ResolverActivity_
+ *
+ * This class must only reference it's host as Activity/ComponentActivity; no down-cast to
+ * [ResolverActivity]. Other components should be created here or supplied via Injection, and not
+ * referenced directly from the activity. This prevents circular dependencies from forming. If
+ * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described
+ * above in 'Incoming References', see [ResolverInitializer].
+ *
+ * _Elsewhere_
+ *
+ * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of
+ * referenced from an existing location. If not available for injection, the value should be
+ * constructed here, then provided to where it is needed.
+ */
+@ActivityScoped
+@JavaInterop
+class ResolverHelper
+@Inject
+constructor(
+ hostActivity: Activity,
+ private val userInteractor: UserInteractor,
+ @Background private val background: CoroutineDispatcher,
+) : DefaultLifecycleObserver {
+ // This is guaranteed by Hilt, since only a ComponentActivity is injectable.
+ private val activity: ComponentActivity = hostActivity as ComponentActivity
+ private val viewModel by activity.viewModels<ResolverViewModel>()
+
+ private lateinit var activityInitializer: Runnable
+
+ init {
+ activity.lifecycle.addObserver(this)
+ }
+
+ /**
+ * Set the initialization hook for the host activity.
+ *
+ * This _must_ be called from [ResolverActivity.onCreate].
+ */
+ fun setInitializer(initializer: Runnable) {
+ if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) {
+ error("setInitializer must be called before onCreate returns")
+ }
+ activityInitializer = initializer
+ }
+
+ /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */
+ override fun onCreate(owner: LifecycleOwner) {
+ Log.i(TAG, "CREATE")
+ Log.i(TAG, "${viewModel.activityModel}")
+
+ val callerUid: Int = viewModel.activityModel.launchedFromUid
+ if (callerUid < 0 || UserHandle.isIsolated(callerUid)) {
+ Log.e(TAG, "Can't start a resolver from uid $callerUid")
+ activity.finish()
+ return
+ }
+
+ when (val request = viewModel.initialRequest) {
+ is Valid -> initializeActivity(request)
+ is Invalid -> reportErrorsAndFinish(request)
+ }
+ }
+
+ private fun reportErrorsAndFinish(request: Invalid<ResolverRequest>) {
+ request.errors.forEach { it.log(TAG) }
+ activity.finish()
+ }
+
+ private fun initializeActivity(request: Valid<ResolverRequest>) {
+ Log.d(TAG, "initializeActivity")
+ request.warnings.forEach { it.log(TAG) }
+
+ activityInitializer.run()
+ }
+}
diff --git a/java/src/com/android/intentresolver/ResolverInfoHelpers.kt b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt
new file mode 100644
index 00000000..8d1d8658
--- /dev/null
+++ b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt
@@ -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.
+ */
+
+@file:JvmName("ResolveInfoHelpers")
+
+package com.android.intentresolver
+
+import android.content.pm.ActivityInfo
+import android.content.pm.ResolveInfo
+
+fun resolveInfoMatch(lhs: ResolveInfo?, rhs: ResolveInfo?): Boolean =
+ (lhs === rhs) ||
+ ((lhs != null && rhs != null) &&
+ activityInfoMatch(lhs.activityInfo, rhs.activityInfo) &&
+ // Comparing against resolveInfo.userHandle in case cloned apps are present,
+ // as they will have the same activityInfo.
+ lhs.userHandle == rhs.userHandle)
+
+private fun activityInfoMatch(lhs: ActivityInfo?, rhs: ActivityInfo?): Boolean =
+ (lhs === rhs) ||
+ (lhs != null && rhs != null && lhs.name == rhs.name && lhs.packageName == rhs.packageName)
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index 282a672f..f29553eb 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -16,16 +16,15 @@
package com.android.intentresolver;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
+import static com.android.intentresolver.Flags.unselectFinalItem;
+import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix;
+
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.LabeledIntent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
-import android.graphics.ColorMatrix;
-import android.graphics.ColorMatrixColorFilter;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.RemoteException;
@@ -42,8 +41,14 @@ import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.icons.LabelInfo;
import com.android.intentresolver.icons.TargetDataLoader;
import com.android.internal.annotations.VisibleForTesting;
@@ -53,17 +58,16 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
public class ResolverListAdapter extends BaseAdapter {
private static final String TAG = "ResolverListAdapter";
- @Nullable // TODO: other model for lazy computation? Or just precompute?
- private static ColorMatrixColorFilter sSuspendedMatrixColorFilter;
-
protected final Context mContext;
protected final LayoutInflater mInflater;
protected final ResolverListCommunicator mResolverListCommunicator;
- protected final ResolverListController mResolverListController;
+ public final ResolverListController mResolverListController;
private final List<Intent> mIntents;
private final Intent[] mInitialIntents;
@@ -75,6 +79,9 @@ public class ResolverListAdapter extends BaseAdapter {
private final Set<DisplayResolveInfo> mRequestedIcons = new HashSet<>();
private final Set<DisplayResolveInfo> mRequestedLabels = new HashSet<>();
+ private final Executor mBgExecutor;
+ private final Executor mCallbackExecutor;
+ private final AtomicBoolean mDestroyed = new AtomicBoolean();
private ResolveInfo mLastChosen;
private DisplayResolveInfo mOtherProfile;
@@ -86,7 +93,6 @@ public class ResolverListAdapter extends BaseAdapter {
private int mLastChosenPosition = -1;
private final boolean mFilterLastUsed;
- private Runnable mPostListReadyRunnable;
private boolean mIsTabLoaded;
// Represents the UserSpace in which the Initial Intents should be resolved.
private final UserHandle mInitialIntentsUserSpace;
@@ -103,6 +109,37 @@ public class ResolverListAdapter extends BaseAdapter {
ResolverListCommunicator resolverListCommunicator,
UserHandle initialIntentsUserSpace,
TargetDataLoader targetDataLoader) {
+ this(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ initialIntentsUserSpace,
+ targetDataLoader,
+ AsyncTask.SERIAL_EXECUTOR,
+ runnable -> context.getMainThreadHandler().post(runnable));
+ }
+
+ @VisibleForTesting
+ public ResolverListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ ResolverListCommunicator resolverListCommunicator,
+ UserHandle initialIntentsUserSpace,
+ TargetDataLoader targetDataLoader,
+ Executor bgExecutor,
+ Executor callbackExecutor) {
mContext = context;
mIntents = payloadIntents;
mInitialIntents = initialIntents;
@@ -117,6 +154,12 @@ public class ResolverListAdapter extends BaseAdapter {
mTargetIntent = targetIntent;
mResolverListCommunicator = resolverListCommunicator;
mInitialIntentsUserSpace = initialIntentsUserSpace;
+ mBgExecutor = bgExecutor;
+ mCallbackExecutor = callbackExecutor;
+ }
+
+ protected Intent getTargetIntent() {
+ return mTargetIntent;
}
public final DisplayResolveInfo getFirstDisplayResolveInfo() {
@@ -189,18 +232,18 @@ public class ResolverListAdapter extends BaseAdapter {
packageName, userHandle, action);
}
- List<ResolvedComponentInfo> getUnfilteredResolveList() {
+ public List<ResolvedComponentInfo> getUnfilteredResolveList() {
return mUnfilteredResolveList;
}
/**
* Rebuild the list of resolvers. When rebuilding is complete, queue the {@code onPostListReady}
- * callback on the main handler with {@code rebuildCompleted} true.
+ * callback on the callback executor with {@code rebuildCompleted} true.
*
* In some cases some parts will need some asynchronous work to complete. Then this will first
- * immediately queue {@code onPostListReady} (on the main handler) with {@code rebuildCompleted}
- * false; only when the asynchronous work completes will this then go on to queue another
- * {@code onPostListReady} callback with {@code rebuildCompleted} true.
+ * immediately queue {@code onPostListReady} (on the callback executor) with
+ * {@code rebuildCompleted} false; only when the asynchronous work completes will this then go
+ * on to queue another {@code onPostListReady} callback with {@code rebuildCompleted} true.
*
* The {@code doPostProcessing} parameter is used to specify whether to update the UI and
* load additional targets (e.g. direct share) after the list has been rebuilt. We may choose
@@ -212,7 +255,7 @@ public class ResolverListAdapter extends BaseAdapter {
* with {@code rebuildCompleted} true at the end of some newly-launched asynchronous work.
* Otherwise the callback is only queued once, with {@code rebuildCompleted} true.
*/
- protected boolean rebuildList(boolean doPostProcessing) {
+ public boolean rebuildList(boolean doPostProcessing) {
Trace.beginSection("ResolverListAdapter#rebuildList");
mDisplayList.clear();
mIsTabLoaded = false;
@@ -357,18 +400,22 @@ public class ResolverListAdapter extends BaseAdapter {
otherProfileInfo,
mPm,
mTargetIntent,
- mResolverListCommunicator,
- mTargetDataLoader);
+ mResolverListCommunicator
+ );
} else {
mOtherProfile = null;
- try {
- mLastChosen = mResolverListController.getLastChosen();
- // TODO: does this also somehow need to update mLastChosenPosition? If so, maybe
- // the current method should also take responsibility for re-initializing
- // mLastChosenPosition, where it's currently done at the start of rebuildList()?
- // (Why is this related to the presence of mOtherProfile in fhe first place?)
- } catch (RemoteException re) {
- Log.d(TAG, "Error calling getLastChosenActivity\n" + re);
+ // If `mFilterLastUsed` is (`final`) false, we'll never read `mLastChosen`, so don't
+ // bother making the system query.
+ if (mFilterLastUsed) {
+ try {
+ mLastChosen = mResolverListController.getLastChosen();
+ // TODO: does this also somehow need to update mLastChosenPosition? If so, maybe
+ // the current method should also take responsibility for re-initializing
+ // mLastChosenPosition, where it's currently done at the start of rebuildList()?
+ // (Why is this related to the presence of mOtherProfile in fhe first place?)
+ } catch (RemoteException re) {
+ Log.d(TAG, "Error calling getLastChosenActivity\n" + re);
+ }
}
}
}
@@ -402,35 +449,42 @@ public class ResolverListAdapter extends BaseAdapter {
// Send an "incomplete" list-ready while the async task is running.
postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false);
- createSortingTask(doPostProcessing).execute(filteredResolveList);
+ mBgExecutor.execute(() -> {
+ if (isDestroyed()) {
+ return;
+ }
+ List<ResolvedComponentInfo> sortedComponents = null;
+ //TODO: the try-catch logic here is to formally match the AsyncTask's behavior.
+ // Empirically, we don't need it as in the case on an exception, the app will crash and
+ // `onComponentsSorted` won't be invoked.
+ try {
+ sortComponents(filteredResolveList);
+ sortedComponents = filteredResolveList;
+ } catch (Throwable t) {
+ Log.e(TAG, "Failed to sort components", t);
+ throw t;
+ } finally {
+ final List<ResolvedComponentInfo> result = sortedComponents;
+ mCallbackExecutor.execute(() -> onComponentsSorted(result, doPostProcessing));
+ }
+ });
return false;
}
- AsyncTask<List<ResolvedComponentInfo>,
- Void,
- List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) {
- return new AsyncTask<List<ResolvedComponentInfo>,
- Void,
- List<ResolvedComponentInfo>>() {
- @Override
- protected List<ResolvedComponentInfo> doInBackground(
- List<ResolvedComponentInfo>... params) {
- mResolverListController.sort(params[0]);
- return params[0];
- }
- @Override
- protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) {
- processSortedList(sortedComponents, doPostProcessing);
- notifyDataSetChanged();
- if (doPostProcessing) {
- mResolverListCommunicator.updateProfileViewButton();
- }
- }
- };
+ @WorkerThread
+ protected void sortComponents(List<ResolvedComponentInfo> components) {
+ mResolverListController.sort(components);
}
- protected void processSortedList(List<ResolvedComponentInfo> sortedComponents,
- boolean doPostProcessing) {
+ @MainThread
+ protected void onComponentsSorted(
+ @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) {
+ processSortedList(sortedComponents, doPostProcessing);
+ notifyDataSetChanged();
+ }
+
+ protected void processSortedList(
+ @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) {
final int n = sortedComponents != null ? sortedComponents.size() : 0;
Trace.beginSection("ResolverListAdapter#processSortedList:" + n);
if (n != 0) {
@@ -471,8 +525,7 @@ public class ResolverListAdapter extends BaseAdapter {
ri,
ri.loadLabel(mPm),
null,
- ii,
- mTargetDataLoader.createPresentationGetter(ri)));
+ ii));
}
}
@@ -494,23 +547,23 @@ public class ResolverListAdapter extends BaseAdapter {
/**
* Some necessary methods for creating the list are initiated in onCreate and will also
* determine the layout known. We therefore can't update the UI inline and post to the
- * handler thread to update after the current task is finished.
+ * callback executor to update after the current task is finished.
* @param doPostProcessing Whether to update the UI and load additional direct share targets
* after the list has been rebuilt
* @param rebuildCompleted Whether the list has been completely rebuilt
*/
- void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) {
- if (mPostListReadyRunnable == null) {
- mPostListReadyRunnable = new Runnable() {
- @Override
- public void run() {
- mResolverListCommunicator.onPostListReady(ResolverListAdapter.this,
- doPostProcessing, rebuildCompleted);
- mPostListReadyRunnable = null;
+ public void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) {
+ Runnable listReadyRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (mDestroyed.get()) {
+ return;
}
- };
- mContext.getMainThreadHandler().post(mPostListReadyRunnable);
- }
+ mResolverListCommunicator.onPostListReady(ResolverListAdapter.this,
+ doPostProcessing, rebuildCompleted);
+ }
+ };
+ mCallbackExecutor.execute(listReadyRunnable);
}
private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) {
@@ -524,8 +577,7 @@ public class ResolverListAdapter extends BaseAdapter {
final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
intent,
add,
- (replaceIntent != null) ? replaceIntent : defaultIntent,
- mTargetDataLoader.createPresentationGetter(add));
+ (replaceIntent != null) ? replaceIntent : defaultIntent);
dri.setPinned(rci.isPinned());
if (rci.isPinned()) {
Log.i(TAG, "Pinned item: " + rci.name);
@@ -572,7 +624,7 @@ public class ResolverListAdapter extends BaseAdapter {
protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) {
// Checks if this info is already listed in display.
for (DisplayResolveInfo existingInfo : mDisplayList) {
- if (mResolverListCommunicator
+ if (ResolveInfoHelpers
.resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) {
return false;
}
@@ -600,6 +652,7 @@ public class ResolverListAdapter extends BaseAdapter {
return null;
}
+ @Override
public int getCount() {
int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount :
mDisplayList.size();
@@ -613,6 +666,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mDisplayList.size();
}
+ @Override
@Nullable
public TargetInfo getItem(int position) {
if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) {
@@ -625,6 +679,7 @@ public class ResolverListAdapter extends BaseAdapter {
}
}
+ @Override
public long getItemId(int position) {
return position;
}
@@ -642,6 +697,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mDisplayList.get(index);
}
+ @Override
public final View getView(int position, View convertView, ViewGroup parent) {
View view = convertView;
if (view == null) {
@@ -685,52 +741,53 @@ public class ResolverListAdapter extends BaseAdapter {
holder.bindLabel("", "");
loadLabel(dri);
}
- holder.bindIcon(info);
if (!dri.hasDisplayIcon()) {
loadIcon(dri);
}
+ holder.bindIcon(info);
}
}
protected final void loadIcon(DisplayResolveInfo info) {
if (mRequestedIcons.add(info)) {
- mTargetDataLoader.loadAppTargetIcon(
+ Drawable icon = mTargetDataLoader.getOrLoadAppTargetIcon(
info,
getUserHandle(),
- (drawable) -> onIconLoaded(info, drawable));
+ (drawable) -> {
+ onIconLoaded(info, drawable);
+ notifyDataSetChanged();
+ });
+ if (icon != null) {
+ onIconLoaded(info, icon);
+ }
}
}
private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) {
- if (getOtherProfile() == displayResolveInfo) {
- mResolverListCommunicator.updateProfileViewButton();
- } else if (!displayResolveInfo.hasDisplayIcon()) {
+ if (!displayResolveInfo.hasDisplayIcon()) {
displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable);
- notifyDataSetChanged();
}
}
- private void loadLabel(DisplayResolveInfo info) {
+ protected final void loadLabel(DisplayResolveInfo info) {
if (mRequestedLabels.add(info)) {
mTargetDataLoader.loadLabel(info, (result) -> onLabelLoaded(info, result));
}
}
protected final void onLabelLoaded(
- DisplayResolveInfo displayResolveInfo, CharSequence[] result) {
+ DisplayResolveInfo displayResolveInfo, LabelInfo result) {
if (displayResolveInfo.hasDisplayLabel()) {
return;
}
- displayResolveInfo.setDisplayLabel(result[0]);
- displayResolveInfo.setExtendedInfo(result[1]);
+ displayResolveInfo.setDisplayLabel(result.getLabel());
+ displayResolveInfo.setExtendedInfo(result.getSubLabel());
notifyDataSetChanged();
}
public void onDestroy() {
- if (mPostListReadyRunnable != null) {
- mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable);
- mPostListReadyRunnable = null;
- }
+ mDestroyed.set(true);
+
if (mResolverListController != null) {
mResolverListController.destroy();
}
@@ -738,37 +795,18 @@ public class ResolverListAdapter extends BaseAdapter {
mRequestedLabels.clear();
}
- private static ColorMatrixColorFilter getSuspendedColorMatrix() {
- if (sSuspendedMatrixColorFilter == null) {
-
- int grayValue = 127;
- float scale = 0.5f; // half bright
-
- ColorMatrix tempBrightnessMatrix = new ColorMatrix();
- float[] mat = tempBrightnessMatrix.getArray();
- mat[0] = scale;
- mat[6] = scale;
- mat[12] = scale;
- mat[4] = grayValue;
- mat[9] = grayValue;
- mat[14] = grayValue;
-
- ColorMatrix matrix = new ColorMatrix();
- matrix.setSaturation(0.0f);
- matrix.preConcat(tempBrightnessMatrix);
- sSuspendedMatrixColorFilter = new ColorMatrixColorFilter(matrix);
- }
- return sSuspendedMatrixColorFilter;
+ public final boolean isDestroyed() {
+ return mDestroyed.get();
}
protected final Drawable loadIconPlaceholder() {
return mContext.getDrawable(R.drawable.resolver_icon_placeholder);
}
- void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) {
+ public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) {
final DisplayResolveInfo iconInfo = getFilteredItem();
if (iconInfo != null) {
- mTargetDataLoader.loadAppTargetIcon(
+ mTargetDataLoader.getOrLoadAppTargetIcon(
iconInfo, getUserHandle(), iconView::setImageDrawable);
}
}
@@ -777,7 +815,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mUserHandle;
}
- protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) {
+ public final List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) {
return mResolverListController.getResolversForIntentAsUser(
/* shouldGetResolvedFilter= */ true,
mResolverListCommunicator.shouldGetActivityMetadata(),
@@ -786,15 +824,16 @@ public class ResolverListAdapter extends BaseAdapter {
userHandle);
}
- protected List<Intent> getIntents() {
+ public List<Intent> getIntents() {
+ // TODO: immutable copy?
return mIntents;
}
- protected boolean isTabLoaded() {
+ public boolean isTabLoaded() {
return mIsTabLoaded;
}
- protected void markTabLoaded() {
+ public void markTabLoaded() {
mIsTabLoaded = true;
}
@@ -828,8 +867,7 @@ public class ResolverListAdapter extends BaseAdapter {
ResolvedComponentInfo resolvedComponentInfo,
PackageManager pm,
Intent targetIntent,
- ResolverListCommunicator resolverListCommunicator,
- TargetDataLoader targetDataLoader) {
+ ResolverListCommunicator resolverListCommunicator) {
ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0);
Intent pOrigIntent = resolverListCommunicator.getReplacementIntent(
@@ -838,36 +876,34 @@ public class ResolverListAdapter extends BaseAdapter {
Intent replacementIntent = resolverListCommunicator.getReplacementIntent(
resolveInfo.activityInfo, targetIntent);
- TargetPresentationGetter presentationGetter =
- targetDataLoader.createPresentationGetter(resolveInfo);
-
return DisplayResolveInfo.newDisplayResolveInfo(
resolvedComponentInfo.getIntentAt(0),
resolveInfo,
resolveInfo.loadLabel(pm),
resolveInfo.loadLabel(pm),
- pOrigIntent != null ? pOrigIntent : replacementIntent,
- presentationGetter);
+ pOrigIntent != null ? pOrigIntent : replacementIntent);
}
/**
* Necessary methods to communicate between {@link ResolverListAdapter}
* and {@link ResolverActivity}.
*/
- interface ResolverListCommunicator {
-
- boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs);
+ public interface ResolverListCommunicator {
Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent);
+ // ResolverListCommunicator
+ default void updateProfileViewButton() {
+ }
+
void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi,
boolean rebuildCompleted);
void sendVoiceChoicesIfNeeded();
- void updateProfileViewButton();
-
- boolean useLayoutWithDefault();
+ default boolean useLayoutWithDefault() {
+ return false;
+ }
boolean shouldGetActivityMetadata();
@@ -875,7 +911,9 @@ public class ResolverListAdapter extends BaseAdapter {
* @return true to filter only apps that can handle
* {@link android.content.Intent#CATEGORY_DEFAULT} intents
*/
- default boolean shouldGetOnlyDefaultActivities() { return true; };
+ default boolean shouldGetOnlyDefaultActivities() {
+ return true;
+ }
void onHandlePackagesChanged(ResolverListAdapter listAdapter);
}
@@ -887,12 +925,28 @@ public class ResolverListAdapter extends BaseAdapter {
@VisibleForTesting
public static class ViewHolder {
public View itemView;
- public Drawable defaultItemViewBackground;
+ public final Drawable defaultItemViewBackground;
public TextView text;
public TextView text2;
public ImageView icon;
+ public final void reset() {
+ text.setText("");
+ text.setMaxLines(2);
+ text.setMaxWidth(Integer.MAX_VALUE);
+
+ text2.setVisibility(View.GONE);
+ text2.setText("");
+
+ itemView.setContentDescription(null);
+ itemView.setBackground(defaultItemViewBackground);
+
+ icon.setImageDrawable(null);
+ icon.setColorFilter(null);
+ icon.clearAnimation();
+ }
+
@VisibleForTesting
public ViewHolder(View view) {
itemView = view;
@@ -921,20 +975,29 @@ public class ResolverListAdapter extends BaseAdapter {
itemView.setContentDescription(null);
}
- public void updateContentDescription(String description) {
- itemView.setContentDescription(description);
+ /**
+ * Bind view holder to a TargetInfo.
+ */
+ public final void bindIcon(TargetInfo info) {
+ bindIcon(info, true);
}
/**
* Bind view holder to a TargetInfo.
*/
- public void bindIcon(TargetInfo info) {
+ public void bindIcon(TargetInfo info, boolean isEnabled) {
Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon();
icon.setImageDrawable(displayIcon);
- if (info.isSuspended()) {
+ if (info.isSuspended() || !isEnabled) {
icon.setColorFilter(getSuspendedColorMatrix());
} else {
icon.setColorFilter(null);
+ if (unselectFinalItem() && displayIcon != null) {
+ // For some reason, ImageView.setColorFilter() not always propagate the call
+ // to the drawable and the icon remains grayscale when rebound; reset the filter
+ // explicitly.
+ displayIcon.setColorFilter(null);
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java
index d5a5fedf..e88d766d 100644
--- a/java/src/com/android/intentresolver/ResolverListController.java
+++ b/java/src/com/android/intentresolver/ResolverListController.java
@@ -17,7 +17,6 @@
package com.android.intentresolver;
-import android.annotation.WorkerThread;
import android.app.ActivityManager;
import android.app.AppGlobals;
import android.content.ComponentName;
@@ -31,6 +30,8 @@ import android.os.RemoteException;
import android.os.UserHandle;
import android.util.Log;
+import androidx.annotation.WorkerThread;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.model.AbstractResolverComparator;
@@ -254,7 +255,6 @@ public class ResolverListController {
isComputed = true;
}
- @VisibleForTesting
@WorkerThread
public void sort(List<ResolvedComponentInfo> inputList) {
try {
@@ -273,7 +273,6 @@ public class ResolverListController {
}
}
- @VisibleForTesting
@WorkerThread
public void topK(List<ResolvedComponentInfo> inputList, int k) {
if (inputList == null || inputList.isEmpty() || k <= 0) {
@@ -335,7 +334,7 @@ public class ResolverListController {
&& ai.name.equals(b.name.getClassName());
}
- boolean isComponentFiltered(ComponentName componentName) {
+ public boolean isComponentFiltered(ComponentName componentName) {
return false;
}
diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java
index 0804a2b8..891ace87 100644
--- a/java/src/com/android/intentresolver/ResolverViewPager.java
+++ b/java/src/com/android/intentresolver/ResolverViewPager.java
@@ -69,12 +69,18 @@ public class ResolverViewPager extends ViewPager {
* Sets whether swiping sideways should happen.
* <p>Note that swiping is always disabled for RTL layouts (b/159110029 for context).
*/
- void setSwipingEnabled(boolean swipingEnabled) {
+ public void setSwipingEnabled(boolean swipingEnabled) {
mSwipingEnabled = swipingEnabled;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
- return !isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev);
+ return !isEnabled()
+ || (!isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev));
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ return isEnabled() && super.onTouchEvent(ev);
}
}
diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
index 645b9391..3a1a51e3 100644
--- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
+++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
@@ -16,7 +16,8 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
+import static com.android.intentresolver.Flags.rebuildAdaptersOnTargetPinning;
+
import android.app.prediction.AppTarget;
import android.content.Context;
import android.content.Intent;
@@ -26,16 +27,24 @@ import android.content.pm.ShortcutInfo;
import android.service.chooser.ChooserTarget;
import android.util.Log;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.SelectableTargetInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.ui.AppShortcutLimit;
+import com.android.intentresolver.ui.EnforceShortcutLimit;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
-class ShortcutSelectionLogic {
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+@Singleton
+public class ShortcutSelectionLogic {
private static final String TAG = "ShortcutSelectionLogic";
private static final boolean DEBUG = false;
private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f;
@@ -48,9 +57,10 @@ class ShortcutSelectionLogic {
private final Comparator<ChooserTarget> mBaseTargetComparator =
(lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore());
- ShortcutSelectionLogic(
- int maxShortcutTargetsPerApp,
- boolean applySharingAppLimits) {
+ @Inject
+ public ShortcutSelectionLogic(
+ @AppShortcutLimit int maxShortcutTargetsPerApp,
+ @EnforceShortcutLimit boolean applySharingAppLimits) {
mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp;
mApplySharingAppLimits = applySharingAppLimits;
}
@@ -77,7 +87,7 @@ class ShortcutSelectionLogic {
+ targets.size()
+ " targets");
}
- if (targets.size() == 0) {
+ if (targets.isEmpty()) {
return false;
}
Collections.sort(targets, mBaseTargetComparator);
@@ -163,16 +173,21 @@ class ShortcutSelectionLogic {
List<TargetInfo> serviceTargets) {
// Check for duplicates and abort if found
- for (TargetInfo otherTargetInfo : serviceTargets) {
+ for (int i = 0; i < serviceTargets.size(); i++) {
+ TargetInfo otherTargetInfo = serviceTargets.get(i);
if (chooserTargetInfo.isSimilar(otherTargetInfo)) {
+ if (rebuildAdaptersOnTargetPinning()
+ && chooserTargetInfo.isPinned() != otherTargetInfo.isPinned()) {
+ serviceTargets.set(i, chooserTargetInfo);
+ return true;
+ }
return false;
}
}
int currentSize = serviceTargets.size();
final float newScore = chooserTargetInfo.getModifiedScore();
- for (int i = 0; i < Math.min(currentSize, maxRankedTargets);
- i++) {
+ for (int i = 0; i < Math.min(currentSize, maxRankedTargets); i++) {
final TargetInfo serviceTarget = serviceTargets.get(i);
if (serviceTarget == null) {
serviceTargets.set(i, chooserTargetInfo);
diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java
index ec5179ac..afb7d19e 100644
--- a/java/src/com/android/intentresolver/SimpleIconFactory.java
+++ b/java/src/com/android/intentresolver/SimpleIconFactory.java
@@ -21,9 +21,6 @@ import static android.graphics.Paint.DITHER_FLAG;
import static android.graphics.Paint.FILTER_BITMAP_FLAG;
import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction;
-import android.annotation.AttrRes;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.app.ActivityManager;
import android.content.Context;
import android.content.pm.PackageManager;
@@ -50,6 +47,10 @@ import android.util.AttributeSet;
import android.util.Pools.SynchronizedPool;
import android.util.TypedValue;
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
import com.android.internal.annotations.VisibleForTesting;
import org.xmlpull.v1.XmlPullParser;
@@ -57,14 +58,13 @@ import org.xmlpull.v1.XmlPullParser;
import java.nio.ByteBuffer;
import java.util.Optional;
-
/**
* @deprecated Use the Launcher3 Iconloaderlib at packages/apps/Launcher3/iconloaderlib. This class
* is a temporary fork of Iconloader. It combines all necessary methods to render app icons that are
* possibly badged. It is intended to be used only by Sharesheet for the Q release with custom code.
*/
@Deprecated
-public class SimpleIconFactory {
+public class SimpleIconFactory implements AutoCloseable {
private static final SynchronizedPool<SimpleIconFactory> sPool =
@@ -139,6 +139,11 @@ public class SimpleIconFactory {
"Expected theme to define iconfactoryBadgeSize.");
}
+ @Override
+ public void close() {
+ recycle();
+ }
+
/**
* Recycles the SimpleIconFactory so others may use it.
*
@@ -146,9 +151,11 @@ public class SimpleIconFactory {
*/
@Deprecated
public void recycle() {
- // Return to default background color
- setWrapperBackgroundColor(Color.WHITE);
- sPool.release(this);
+ if (sPoolEnabled) {
+ // Return to default background color
+ setWrapperBackgroundColor(Color.WHITE);
+ sPool.release(this);
+ }
}
/**
@@ -719,10 +726,18 @@ public class SimpleIconFactory {
}
@Override
- public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { }
+ public void inflate(
+ @NonNull Resources r,
+ @NonNull XmlPullParser parser,
+ @NonNull AttributeSet attrs) {
+ }
@Override
- public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { }
+ public void inflate(
+ @NonNull Resources r,
+ @NonNull XmlPullParser parser,
+ @NonNull AttributeSet attrs, Theme theme) {
+ }
/**
* Sets the scale associated with this drawable
diff --git a/java/src/com/android/intentresolver/StartsSelectedItem.kt b/java/src/com/android/intentresolver/StartsSelectedItem.kt
new file mode 100644
index 00000000..01cdf124
--- /dev/null
+++ b/java/src/com/android/intentresolver/StartsSelectedItem.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+interface StartsSelectedItem {
+ /** Start the selected item. */
+ fun startSelected(which: Int, always: Boolean, filtered: Boolean)
+}
diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java
index f8b36566..3a7f807d 100644
--- a/java/src/com/android/intentresolver/TargetPresentationGetter.java
+++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java
@@ -16,20 +16,21 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
-import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.Nullable;
+
+import javax.inject.Provider;
+
/**
* Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon
* and label over any IntentFilter or Activity icon to increase user understanding, with an
@@ -37,7 +38,7 @@ import android.util.Log;
* resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses
* Strings to strip creative formatting.
*
- * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the
+ * Use one of the {@link TargetPresentationGetter.Factory} methods to create an instance of the
* appropriate concrete type.
*
* TODO: once this component (and its tests) are merged, it should be possible to refactor and
@@ -48,22 +49,29 @@ public abstract class TargetPresentationGetter {
/** Helper to build appropriate type-specific {@link TargetPresentationGetter} instances. */
public static class Factory {
- private final Context mContext;
+ private final Provider<SimpleIconFactory> mIconFactoryProvider;
+ private final PackageManager mPackageManager;
private final int mIconDpi;
- public Factory(Context context, int iconDpi) {
- mContext = context;
+ public Factory(
+ Provider<SimpleIconFactory> iconfactoryProvider,
+ PackageManager packageManager,
+ int iconDpi) {
+ mIconFactoryProvider = iconfactoryProvider;
+ mPackageManager = packageManager;
mIconDpi = iconDpi;
}
/** Make a {@link TargetPresentationGetter} for an {@link ActivityInfo}. */
public TargetPresentationGetter makePresentationGetter(ActivityInfo activityInfo) {
- return new ActivityInfoPresentationGetter(mContext, mIconDpi, activityInfo);
+ return new ActivityInfoPresentationGetter(
+ mIconFactoryProvider, mPackageManager, mIconDpi, activityInfo);
}
/** Make a {@link TargetPresentationGetter} for a {@link ResolveInfo}. */
public TargetPresentationGetter makePresentationGetter(ResolveInfo resolveInfo) {
- return new ResolveInfoPresentationGetter(mContext, mIconDpi, resolveInfo);
+ return new ResolveInfoPresentationGetter(
+ mIconFactoryProvider, mPackageManager, mIconDpi, resolveInfo);
}
}
@@ -76,7 +84,7 @@ public abstract class TargetPresentationGetter {
@Nullable
protected abstract String getAppLabelForSubstitutePermission();
- private Context mContext;
+ private final Provider<SimpleIconFactory> mIconFactoryProvider;
private final int mIconDpi;
private final boolean mHasSubstitutePermission;
private final ApplicationInfo mAppInfo;
@@ -87,14 +95,6 @@ public abstract class TargetPresentationGetter {
* Retrieve the image that should be displayed as the icon when this target is presented to the
* specified {@code userHandle}.
*/
- public Drawable getIcon(UserHandle userHandle) {
- return new BitmapDrawable(mContext.getResources(), getIconBitmap(userHandle));
- }
-
- /**
- * Retrieve the image that should be displayed as the icon when this target is presented to the
- * specified {@code userHandle}.
- */
public Bitmap getIconBitmap(@Nullable UserHandle userHandle) {
Drawable drawable = null;
if (mHasSubstitutePermission) {
@@ -115,9 +115,10 @@ public abstract class TargetPresentationGetter {
drawable = mAppInfo.loadIcon(mPm);
}
- SimpleIconFactory iconFactory = SimpleIconFactory.obtain(mContext);
- Bitmap icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle);
- iconFactory.recycle();
+ Bitmap icon;
+ try (SimpleIconFactory iconFactory = mIconFactoryProvider.get()) {
+ icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle);
+ }
return icon;
}
@@ -167,9 +168,13 @@ public abstract class TargetPresentationGetter {
return res.getDrawableForDensity(resId, mIconDpi);
}
- private TargetPresentationGetter(Context context, int iconDpi, ApplicationInfo appInfo) {
- mContext = context;
- mPm = context.getPackageManager();
+ private TargetPresentationGetter(
+ Provider<SimpleIconFactory> iconfactoryProvider,
+ PackageManager packageManager,
+ int iconDpi,
+ ApplicationInfo appInfo) {
+ mIconFactoryProvider = iconfactoryProvider;
+ mPm = packageManager;
mAppInfo = appInfo;
mIconDpi = iconDpi;
mHasSubstitutePermission = (PackageManager.PERMISSION_GRANTED == mPm.checkPermission(
@@ -182,8 +187,11 @@ public abstract class TargetPresentationGetter {
private final ResolveInfo mResolveInfo;
ResolveInfoPresentationGetter(
- Context context, int iconDpi, ResolveInfo resolveInfo) {
- super(context, iconDpi, resolveInfo.activityInfo);
+ Provider<SimpleIconFactory> iconfactoryProvider,
+ PackageManager packageManager,
+ int iconDpi,
+ ResolveInfo resolveInfo) {
+ super(iconfactoryProvider, packageManager, iconDpi, resolveInfo.activityInfo);
mResolveInfo = resolveInfo;
}
@@ -229,8 +237,11 @@ public abstract class TargetPresentationGetter {
private final ActivityInfo mActivityInfo;
ActivityInfoPresentationGetter(
- Context context, int iconDpi, ActivityInfo activityInfo) {
- super(context, iconDpi, activityInfo.applicationInfo);
+ Provider<SimpleIconFactory> iconfactoryProvider,
+ PackageManager packageManager,
+ int iconDpi,
+ ActivityInfo activityInfo) {
+ super(iconfactoryProvider, packageManager, iconDpi, activityInfo.applicationInfo);
mActivityInfo = activityInfo;
}
diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
deleted file mode 100644
index 2f3dfbd5..00000000
--- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver;
-
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
-
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.app.admin.DevicePolicyEventLogger;
-import android.app.admin.DevicePolicyManager;
-import android.content.Context;
-import android.os.UserHandle;
-import android.stats.devicepolicy.nano.DevicePolicyEnums;
-
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
-
-/**
- * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
- * work profile is paused and we need to show a button to enable it.
- */
-public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
-
- private final UserHandle mWorkProfileUserHandle;
- private final WorkProfileAvailabilityManager mWorkProfileAvailability;
- private final String mMetricsCategory;
- private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
- private final Context mContext;
-
- public WorkProfilePausedEmptyStateProvider(@NonNull Context context,
- @Nullable UserHandle workProfileUserHandle,
- @NonNull WorkProfileAvailabilityManager workProfileAvailability,
- @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
- @NonNull String metricsCategory) {
- mContext = context;
- mWorkProfileUserHandle = workProfileUserHandle;
- mWorkProfileAvailability = workProfileAvailability;
- mMetricsCategory = metricsCategory;
- mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
- }
-
- @Nullable
- @Override
- public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
- if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle)
- || !mWorkProfileAvailability.isQuietModeEnabled()
- || resolverListAdapter.getCount() == 0) {
- return null;
- }
-
- final String title = mContext.getSystemService(DevicePolicyManager.class)
- .getResources().getString(RESOLVER_WORK_PAUSED_TITLE,
- () -> mContext.getString(R.string.resolver_turn_on_work_apps));
-
- return new WorkProfileOffEmptyState(title, (tab) -> {
- tab.showSpinner();
- if (mOnSwitchOnWorkSelectedListener != null) {
- mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
- }
- mWorkProfileAvailability.requestQuietModeEnabled(false);
- }, mMetricsCategory);
- }
-
- public static class WorkProfileOffEmptyState implements EmptyState {
-
- private final String mTitle;
- private final ClickListener mOnClick;
- private final String mMetricsCategory;
-
- public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick,
- @NonNull String metricsCategory) {
- mTitle = title;
- mOnClick = onClick;
- mMetricsCategory = metricsCategory;
- }
-
- @Nullable
- @Override
- public String getTitle() {
- return mTitle;
- }
-
- @Nullable
- @Override
- public ClickListener getButtonClickListener() {
- return mOnClick;
- }
-
- @Override
- public void onEmptyStateShown() {
- DevicePolicyEventLogger
- .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED)
- .setStrings(mMetricsCategory)
- .write();
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/annotation/JavaInterop.kt
new file mode 100644
index 00000000..e268af98
--- /dev/null
+++ b/java/src/com/android/intentresolver/annotation/JavaInterop.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.annotation
+
+/**
+ * Apply to code which exists specifically to easy integration with existing Java and Java APIs.
+ *
+ * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available.
+ */
+@RequiresOptIn(
+ "This is a a property, function or class specifically supporting Java " +
+ "interoperability. Usage from Kotlin should be limited to interactions with Java."
+)
+annotation class JavaInterop
diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
index 8b9bfb32..074537ef 100644
--- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
@@ -16,6 +16,8 @@
package com.android.intentresolver.chooser;
+import android.service.chooser.ChooserTarget;
+
import java.util.ArrayList;
import java.util.Arrays;
diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
index 09cf319f..e641944e 100644
--- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
@@ -16,8 +16,6 @@
package com.android.intentresolver.chooser;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Intent;
@@ -27,10 +25,10 @@ import android.content.pm.ResolveInfo;
import android.os.Bundle;
import android.os.UserHandle;
-import com.android.intentresolver.TargetPresentationGetter;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
/**
@@ -39,12 +37,11 @@ import java.util.List;
*/
public class DisplayResolveInfo implements TargetInfo {
private final ResolveInfo mResolveInfo;
- private CharSequence mDisplayLabel;
- private CharSequence mExtendedInfo;
+ private volatile CharSequence mDisplayLabel;
+ private volatile CharSequence mExtendedInfo;
private final Intent mResolvedIntent;
private final List<Intent> mSourceIntents = new ArrayList<>();
private final boolean mIsSuspended;
- private TargetPresentationGetter mPresentationGetter;
private boolean mPinned = false;
private final IconHolder mDisplayIconHolder = new SettableIconHolder();
@@ -52,15 +49,13 @@ public class DisplayResolveInfo implements TargetInfo {
public static DisplayResolveInfo newDisplayResolveInfo(
Intent originalIntent,
ResolveInfo resolveInfo,
- @NonNull Intent resolvedIntent,
- @Nullable TargetPresentationGetter presentationGetter) {
+ @NonNull Intent resolvedIntent) {
return newDisplayResolveInfo(
originalIntent,
resolveInfo,
/* displayLabel=*/ null,
/* extendedInfo=*/ null,
- resolvedIntent,
- presentationGetter);
+ resolvedIntent);
}
/** Create a new {@code DisplayResolveInfo} instance. */
@@ -69,15 +64,13 @@ public class DisplayResolveInfo implements TargetInfo {
ResolveInfo resolveInfo,
CharSequence displayLabel,
CharSequence extendedInfo,
- @NonNull Intent resolvedIntent,
- @Nullable TargetPresentationGetter presentationGetter) {
+ @NonNull Intent resolvedIntent) {
return new DisplayResolveInfo(
originalIntent,
resolveInfo,
displayLabel,
extendedInfo,
- resolvedIntent,
- presentationGetter);
+ resolvedIntent);
}
private DisplayResolveInfo(
@@ -85,13 +78,11 @@ public class DisplayResolveInfo implements TargetInfo {
ResolveInfo resolveInfo,
CharSequence displayLabel,
CharSequence extendedInfo,
- @NonNull Intent resolvedIntent,
- @Nullable TargetPresentationGetter presentationGetter) {
+ @NonNull Intent resolvedIntent) {
mSourceIntents.add(originalIntent);
mResolveInfo = resolveInfo;
mDisplayLabel = displayLabel;
mExtendedInfo = extendedInfo;
- mPresentationGetter = presentationGetter;
final ActivityInfo ai = mResolveInfo.activityInfo;
mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0;
@@ -101,8 +92,7 @@ public class DisplayResolveInfo implements TargetInfo {
private DisplayResolveInfo(
DisplayResolveInfo other,
- @Nullable Intent baseIntentToSend,
- TargetPresentationGetter presentationGetter) {
+ @Nullable Intent baseIntentToSend) {
mSourceIntents.addAll(other.getAllSourceIntents());
mResolveInfo = other.mResolveInfo;
mIsSuspended = other.mIsSuspended;
@@ -112,7 +102,6 @@ public class DisplayResolveInfo implements TargetInfo {
mResolvedIntent = createResolvedIntent(
baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend,
mResolveInfo.activityInfo);
- mPresentationGetter = presentationGetter;
mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
}
@@ -124,7 +113,6 @@ public class DisplayResolveInfo implements TargetInfo {
mDisplayLabel = other.mDisplayLabel;
mExtendedInfo = other.mExtendedInfo;
mResolvedIntent = other.mResolvedIntent;
- mPresentationGetter = other.mPresentationGetter;
mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
}
@@ -147,10 +135,6 @@ public class DisplayResolveInfo implements TargetInfo {
}
public CharSequence getDisplayLabel() {
- if (mDisplayLabel == null && mPresentationGetter != null) {
- mDisplayLabel = mPresentationGetter.getLabel();
- mExtendedInfo = mPresentationGetter.getSubLabel();
- }
return mDisplayLabel;
}
@@ -186,8 +170,7 @@ public class DisplayResolveInfo implements TargetInfo {
return new DisplayResolveInfo(
this,
- TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement),
- mPresentationGetter);
+ TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement));
}
@Override
@@ -197,7 +180,7 @@ public class DisplayResolveInfo implements TargetInfo {
@Override
public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
- return new ArrayList<>(Arrays.asList(this));
+ return new ArrayList<>(List.of(this));
}
public void addAlternateSourceIntent(Intent alt) {
@@ -213,6 +196,7 @@ public class DisplayResolveInfo implements TargetInfo {
}
@Override
+ @NonNull
public ComponentName getResolvedComponentName() {
return new ComponentName(mResolveInfo.activityInfo.packageName,
mResolveInfo.activityInfo.name);
@@ -221,6 +205,7 @@ public class DisplayResolveInfo implements TargetInfo {
@Override
public boolean startAsCaller(Activity activity, Bundle options, int userId) {
TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId);
+ TargetInfo.refreshIntentCreatorToken(mResolvedIntent);
activity.startActivityAsCaller(mResolvedIntent, options, false, userId);
return true;
}
@@ -228,6 +213,7 @@ public class DisplayResolveInfo implements TargetInfo {
@Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier());
+ TargetInfo.refreshIntentCreatorToken(mResolvedIntent);
// TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If
// so, we can consolidate on the one API method to show that this flag is the only
// distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into
@@ -255,4 +241,11 @@ public class DisplayResolveInfo implements TargetInfo {
public void setPinned(boolean pinned) {
mPinned = pinned;
}
+
+ /**
+ * Creates a copy of the object.
+ */
+ public DisplayResolveInfo copy() {
+ return new DisplayResolveInfo(this);
+ }
}
diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java
new file mode 100644
index 00000000..3462b726
--- /dev/null
+++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.chooser;
+
+
+import android.content.Context;
+
+import java.text.Collator;
+import java.util.Comparator;
+
+/**
+ * Sort intents alphabetically based on display label.
+ */
+public class DisplayResolveInfoAzInfoComparator implements Comparator<DisplayResolveInfo> {
+ Comparator<DisplayResolveInfo> mComparator;
+ public DisplayResolveInfoAzInfoComparator(Context context) {
+ Collator collator = Collator
+ .getInstance(context.getResources().getConfiguration().locale);
+ // Adding two stage comparator, first stage compares using displayLabel, next stage
+ // compares using resolveInfo.userHandle
+ mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator)
+ .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier());
+ }
+
+ @Override
+ public int compare(
+ DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) {
+ return mComparator.compare(lhsp, rhsp);
+ }
+}
diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
index 10d4415a..50aaec0b 100644
--- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
@@ -16,8 +16,6 @@
package com.android.intentresolver.chooser;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.app.Activity;
import android.app.prediction.AppTarget;
import android.content.ComponentName;
@@ -27,8 +25,11 @@ import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
import android.os.Bundle;
import android.os.UserHandle;
+import android.service.chooser.ChooserTarget;
import android.util.HashedStringCache;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -43,7 +44,7 @@ import java.util.List;
public final class ImmutableTargetInfo implements TargetInfo {
private static final String TAG = "TargetInfo";
- /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */
+ /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics}. */
public interface TargetHashProvider {
/** Request a hash for the specified {@code target}. */
HashedStringCache.HashResult getHashedTargetIdForMetrics(
@@ -53,15 +54,15 @@ public final class ImmutableTargetInfo implements TargetInfo {
/** Delegate interface to request that the target be launched by a particular API. */
public interface TargetActivityStarter {
/**
- * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the
- * specified {@code target}.
+ * Request that the delegate use the {@link Activity#startActivityAsCaller} API to launch
+ * the specified {@code target}.
*
* @return true if the target was launched successfully.
*/
boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId);
/**
- * Request that the delegate use the {@link Activity#startAsUser()} API to launch the
+ * Request that the delegate use the {@link Activity#startActivityAsUser} API to launch the
* specified {@code target}.
*
* @return true if the target was launched successfully.
@@ -145,7 +146,7 @@ public final class ImmutableTargetInfo implements TargetInfo {
/**
* Configure an {@link Intent} to be built in to the output target as the "base intent to
* send," which may be a refinement of any of our source targets. This is private because
- * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever
+ * it's only used internally by {@link #tryToCloneWithAppliedRefinement}; if it's ever
* expanded, the builder should probably be responsible for enforcing the refinement check.
*/
private Builder setBaseIntentToSend(Intent baseIntent) {
@@ -229,8 +230,8 @@ public final class ImmutableTargetInfo implements TargetInfo {
/**
* Configure the full list of source intents we could resolve for this target. This is
- * effectively the same as calling {@link #setResolvedIntent()} with the first element of
- * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those
+ * effectively the same as calling {@link #setResolvedIntent} with the first element of
+ * the list, and {@link #setAlternateSourceIntents} with the remainder (or clearing those
* fields on the builder if there are no corresponding elements in the list).
*/
public Builder setAllSourceIntents(List<Intent> sourceIntents) {
diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
index b97e6b45..95cb443e 100644
--- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
@@ -17,10 +17,13 @@
package com.android.intentresolver.chooser;
import android.app.Activity;
+import android.content.ComponentName;
import android.content.Intent;
import android.os.Bundle;
import android.os.UserHandle;
+import android.util.Log;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
@@ -121,6 +124,20 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo {
}
@Override
+ @NonNull
+ public ComponentName getResolvedComponentName() {
+ if (hasSelected()) {
+ return mTargetInfos.get(mSelected).getResolvedComponentName();
+ }
+ // It is not expected to have this method be called on an unselected multi-display item.
+ // Call super to preserve the legacy (most likely erroneous) behavior.
+ Log.wtf(
+ "ChooserActivity",
+ "retrieving ResolvedComponentName from an unselected MultiDisplayResolveInfo");
+ return super.getResolvedComponentName();
+ }
+
+ @Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
return mTargetInfos.get(mSelected).startAsUser(activity, options, user);
}
diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
index 6444e13b..46803a04 100644
--- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
@@ -16,7 +16,6 @@
package com.android.intentresolver.chooser;
-import android.annotation.Nullable;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.AnimatedVectorDrawable;
@@ -24,6 +23,8 @@ import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.UserHandle;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.R;
import java.util.function.Supplier;
diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
index 5766db0e..2658f3e5 100644
--- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
@@ -16,7 +16,6 @@
package com.android.intentresolver.chooser;
-import android.annotation.Nullable;
import android.app.Activity;
import android.app.prediction.AppTarget;
import android.content.ComponentName;
@@ -33,6 +32,8 @@ import android.text.SpannableStringBuilder;
import android.util.HashedStringCache;
import android.util.Log;
+import androidx.annotation.Nullable;
+
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import java.util.ArrayList;
@@ -228,6 +229,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo {
intent.setComponent(getChooserTargetComponentName());
intent.putExtras(mChooserTargetIntentExtras);
TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId);
+ TargetInfo.refreshIntentCreatorToken(intent);
// Important: we will ignore the target security checks in ActivityManager if and
// only if the ChooserTarget's target package is the same package where we got the
diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java
index 9d793994..0935c6e8 100644
--- a/java/src/com/android/intentresolver/chooser/TargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java
@@ -17,21 +17,32 @@
package com.android.intentresolver.chooser;
-import android.annotation.Nullable;
+import static android.security.Flags.preventIntentRedirect;
+
import android.app.Activity;
+import android.app.ActivityManager;
import android.app.prediction.AppTarget;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
+import android.os.RemoteException;
import android.os.UserHandle;
import android.service.chooser.ChooserTarget;
import android.text.TextUtils;
import android.util.HashedStringCache;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.ChooserRefinementManager;
+import com.android.intentresolver.ResolverActivity;
+
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -58,7 +69,7 @@ public interface TargetInfo {
* @param icon the icon to return on subsequent calls to {@link #getDisplayIcon()}.
* Implementations may discard this request as a no-op if they don't support setting.
*/
- void setDisplayIcon(Drawable icon);
+ void setDisplayIcon(@Nullable Drawable icon);
}
/** A simple mutable-container implementation of {@link IconHolder}. */
@@ -71,7 +82,7 @@ public interface TargetInfo {
return mDisplayIcon;
}
- public void setDisplayIcon(Drawable icon) {
+ public void setDisplayIcon(@Nullable Drawable icon) {
mDisplayIcon = icon;
}
}
@@ -187,9 +198,9 @@ public interface TargetInfo {
* Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager}
* received from the caller's refinement flow. This may succeed only if the target has a source
* intent that matches the filtering parameters of the proposed refinement (according to
- * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the
- * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never
- * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add
+ * {@link Intent#filterEquals}). Then the first such match is the "base intent," and the
+ * proposed refinement is merged into that base (via {@link Intent#fillIn}; this can never
+ * result in a change to the {@link Intent#filterEquals} status of the base, but may e.g. add
* new "extras" that weren't previously given in the base intent).
*
* @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of
@@ -280,7 +291,7 @@ public interface TargetInfo {
}
/**
- * @return the {@link ShortcutManager} data for any shortcut associated with this target.
+ * @return the {@link ShortcutInfo} for any shortcut associated with this target.
*/
@Nullable
default ShortcutInfo getDirectShareShortcutInfo() {
@@ -422,7 +433,7 @@ public interface TargetInfo {
/**
* @return true if this target should be logged with the "direct_share" metrics category in
- * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy
+ * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch}. This is defined for legacy
* compatibility and is <em>not</em> likely to be a good indicator of whether this is actually a
* "direct share" target (e.g. because it historically also applies to "empty" and "placeholder"
* targets).
@@ -456,6 +467,22 @@ public interface TargetInfo {
}
/**
+ * refreshes intent's creatorToken with its current intent key fields. This allows
+ * ChooserActivity to still keep original creatorToken's creator uid after making changes to
+ * the intent and still keep it valid.
+ * @param intent the intent's creatorToken needs to up refreshed.
+ */
+ static void refreshIntentCreatorToken(Intent intent) {
+ if (!preventIntentRedirect()) return;
+ try {
+ intent.setCreatorToken(ActivityManager.getService().refreshIntentCreatorToken(
+ intent.cloneForCreatorToken()));
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failure from system", e);
+ }
+ }
+
+ /**
* Derive a "complete" intent from a proposed `refinement` intent by merging it into a matching
* `base` intent, without modifying the filter-equality properties of the `base` intent, while
* still allowing the `refinement` to replace Share "payload" fields.
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index d279f11f..2af5881f 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -16,29 +16,32 @@
package com.android.intentresolver.contentpreview;
-import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
-
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
import android.content.ClipData;
-import android.content.Intent;
import android.content.res.Resources;
import android.net.Uri;
import android.text.TextUtils;
import android.view.LayoutInflater;
+import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
-import androidx.lifecycle.Lifecycle;
+import com.android.intentresolver.ContentTypeHint;
+import com.android.intentresolver.data.model.ChooserRequest;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
+import kotlinx.coroutines.CoroutineScope;
+
import java.util.List;
import java.util.function.Consumer;
+import java.util.function.Supplier;
/**
* Collection of helpers for building the content preview UI displayed in
@@ -47,7 +50,7 @@ import java.util.function.Consumer;
*/
public final class ChooserContentPreviewUi {
- private final Lifecycle mLifecycle;
+ private final CoroutineScope mScope;
/**
* Delegate to build the default system action buttons to display in the preview layout, if/when
@@ -74,7 +77,9 @@ public final class ChooserContentPreviewUi {
* Provides a share modification action, if any.
*/
@Nullable
- ActionRow.Action getModifyShareAction();
+ default ActionRow.Action getModifyShareAction() {
+ return null;
+ }
/**
* <p>
@@ -90,24 +95,33 @@ public final class ChooserContentPreviewUi {
@VisibleForTesting
final ContentPreviewUi mContentPreviewUi;
+ private final Supplier</*@Nullable*/ActionRow.Action> mModifyShareActionFactory;
+ private View mHeadlineParent;
public ChooserContentPreviewUi(
- Lifecycle lifecycle,
+ CoroutineScope scope,
PreviewDataProvider previewData,
- Intent targetIntent,
+ ChooserRequest chooserRequest,
ImageLoader imageLoader,
ActionFactory actionFactory,
+ Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
- mLifecycle = lifecycle;
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata) {
+ mScope = scope;
+ mModifyShareActionFactory = modifyShareActionFactory;
mContentPreviewUi = createContentPreview(
previewData,
- targetIntent,
+ chooserRequest,
DefaultMimeTypeClassifier.INSTANCE,
imageLoader,
actionFactory,
transitionElementStatusCallback,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint,
+ metadata
+ );
if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
transitionElementStatusCallback.onAllTransitionElementsReady();
}
@@ -115,51 +129,67 @@ public final class ChooserContentPreviewUi {
private ContentPreviewUi createContentPreview(
PreviewDataProvider previewData,
- Intent targetIntent,
+ ChooserRequest chooserRequest,
MimeTypeClassifier typeClassifier,
ImageLoader imageLoader,
ActionFactory actionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
-
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata
+ ) {
int previewType = previewData.getPreviewType();
if (previewType == CONTENT_PREVIEW_TEXT) {
return createTextPreview(
- mLifecycle,
- targetIntent,
+ mScope,
+ chooserRequest.getTargetIntent().getClipData(),
+ chooserRequest.getSharedText(),
+ chooserRequest.getSharedTextTitle(),
actionFactory,
imageLoader,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint,
+ metadata
+ );
}
if (previewType == CONTENT_PREVIEW_FILE) {
FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi(
previewData.getUriCount(),
actionFactory,
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
if (previewData.getUriCount() > 0) {
- previewData.getFirstFileName(
- mLifecycle, fileContentPreviewUi::setFirstFileName);
+ previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName);
}
return fileContentPreviewUi;
}
+
+ if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION) {
+ transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO
+ return new ShareouselContentPreviewUi();
+ }
+
boolean isSingleImageShare = previewData.getUriCount() == 1
- && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
- CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- if (!TextUtils.isEmpty(text)) {
+ && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
+ if (!TextUtils.isEmpty(chooserRequest.getSharedText())) {
FilesPlusTextContentPreviewUi previewUi =
new FilesPlusTextContentPreviewUi(
- mLifecycle,
+ mScope,
isSingleImageShare,
previewData.getUriCount(),
- targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT),
- targetIntent.getType(),
+ chooserRequest.getSharedText(),
+ chooserRequest.getTargetType(),
actionFactory,
imageLoader,
typeClassifier,
- headlineGenerator);
+ headlineGenerator,
+ metadata,
+ chooserRequest.getCallerAllowsTextToggle()
+ );
if (previewData.getUriCount() > 0) {
JavaFlowHelper.collectToList(
- getCoroutineScope(mLifecycle),
+ mScope,
previewData.getImagePreviewFileInfoFlow(),
previewUi::updatePreviewMetadata);
}
@@ -167,16 +197,18 @@ public final class ChooserContentPreviewUi {
}
return new UnifiedContentPreviewUi(
- getCoroutineScope(mLifecycle),
+ mScope,
isSingleImageShare,
- targetIntent.getType(),
+ chooserRequest.getTargetType(),
actionFactory,
imageLoader,
typeClassifier,
transitionElementStatusCallback,
previewData.getImagePreviewFileInfoFlow(),
previewData.getUriCount(),
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
}
public int getPreferredContentPreview() {
@@ -188,20 +220,36 @@ public final class ChooserContentPreviewUi {
* specified {@code intent}.
*/
public ViewGroup displayContentPreview(
- Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
+
+ ViewGroup layout =
+ mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent);
+ mHeadlineParent = headlineViewParent;
+ ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get());
+ return layout;
+ }
- return mContentPreviewUi.display(resources, layoutInflater, parent);
+ /**
+ * Update Modify Share Action, if it is inflated.
+ */
+ public void updateModifyShareAction() {
+ ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get());
}
private static TextContentPreviewUi createTextPreview(
- Lifecycle lifecycle,
- Intent targetIntent,
+ CoroutineScope scope,
+ ClipData previewData,
+ @Nullable CharSequence sharingText,
+ @Nullable CharSequence previewTitle,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
- HeadlineGenerator headlineGenerator) {
- CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
- ClipData previewData = targetIntent.getClipData();
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata
+ ) {
Uri previewThumbnail = null;
if (previewData != null) {
if (previewData.getItemCount() > 0) {
@@ -209,13 +257,16 @@ public final class ChooserContentPreviewUi {
previewThumbnail = previewDataItem.getUri();
}
}
+
return new TextContentPreviewUi(
- lifecycle,
+ scope,
sharingText,
previewTitle,
+ metadata,
previewThumbnail,
actionFactory,
imageLoader,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
index ebab147d..79bb9d3c 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
@@ -18,18 +18,20 @@ package com.android.intentresolver.contentpreview;
import static java.lang.annotation.RetentionPolicy.SOURCE;
-import android.annotation.IntDef;
+import androidx.annotation.IntDef;
import java.lang.annotation.Retention;
@Retention(SOURCE)
@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE,
ContentPreviewType.CONTENT_PREVIEW_IMAGE,
- ContentPreviewType.CONTENT_PREVIEW_TEXT})
+ ContentPreviewType.CONTENT_PREVIEW_TEXT,
+ ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION})
public @interface ContentPreviewType {
// Starting at 1 since 0 is considered "undefined" for some of the database transformations
// of tron logs.
int CONTENT_PREVIEW_IMAGE = 1;
int CONTENT_PREVIEW_FILE = 2;
int CONTENT_PREVIEW_TEXT = 3;
+ int CONTENT_PREVIEW_PAYLOAD_SELECTION = 4;
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
index 2d81794e..8eaf3568 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -24,15 +24,20 @@ import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewStub;
import android.view.animation.DecelerateInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
-abstract class ContentPreviewUi {
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+public abstract class ContentPreviewUi {
private static final int IMAGE_FADE_IN_MILLIS = 150;
static final String TAG = "ChooserPreview";
@@ -40,7 +45,10 @@ abstract class ContentPreviewUi {
public abstract int getType();
public abstract ViewGroup display(
- Resources resources, LayoutInflater layoutInflater, ViewGroup parent);
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent);
protected static void updateViewWithImage(ImageView imageView, Bitmap image) {
if (image == null) {
@@ -57,31 +65,52 @@ abstract class ContentPreviewUi {
fadeAnim.start();
}
- protected static void displayHeadline(ViewGroup layout, String headline) {
- if (layout != null) {
- TextView titleView = layout.findViewById(R.id.headline);
- if (titleView != null) {
- if (!TextUtils.isEmpty(headline)) {
- titleView.setText(headline);
- titleView.setVisibility(View.VISIBLE);
- } else {
- titleView.setVisibility(View.GONE);
- }
- }
+ protected static void inflateHeadline(View layout) {
+ ViewStub stub = layout.findViewById(R.id.chooser_headline_row_stub);
+ if (stub != null) {
+ stub.inflate();
}
}
- protected static void displayModifyShareAction(
- ViewGroup layout,
- ChooserContentPreviewUi.ActionFactory actionFactory) {
- ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction();
- if (modifyShareAction != null && layout != null) {
- TextView modifyShareView = layout.findViewById(R.id.reselection_action);
- if (modifyShareView != null) {
- modifyShareView.setText(modifyShareAction.getLabel());
- modifyShareView.setVisibility(View.VISIBLE);
- modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run());
- }
+ protected static void displayHeadline(View layout, String headline) {
+ TextView titleView = layout == null ? null : layout.findViewById(R.id.headline);
+ if (titleView == null) {
+ return;
+ }
+ if (!TextUtils.isEmpty(headline)) {
+ titleView.setText(headline);
+ titleView.setVisibility(View.VISIBLE);
+ } else {
+ titleView.setVisibility(View.GONE);
+ }
+ }
+
+ protected static void displayMetadata(View layout, @Nullable CharSequence metadata) {
+ TextView metadataView = layout == null ? null : layout.findViewById(R.id.metadata);
+ if (metadataView == null) {
+ return;
+ }
+ if (!TextUtils.isEmpty(metadata)) {
+ metadataView.setText(metadata);
+ metadataView.setVisibility(View.VISIBLE);
+ } else {
+ metadataView.setVisibility(View.GONE);
+ }
+ }
+
+ static void displayModifyShareAction(
+ View layout, @Nullable ActionRow.Action modifyShareAction) {
+ TextView modifyShareView =
+ layout == null ? null : layout.findViewById(R.id.reselection_action);
+ if (modifyShareView == null) {
+ return;
+ }
+ if (modifyShareAction != null) {
+ modifyShareView.setText(modifyShareAction.getLabel());
+ modifyShareView.setVisibility(View.VISIBLE);
+ modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run());
+ } else {
+ modifyShareView.setVisibility(View.GONE);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
index 20758189..1749c6f7 100644
--- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -43,15 +43,20 @@ class FileContentPreviewUi extends ContentPreviewUi {
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final HeadlineGenerator mHeadlineGenerator;
@Nullable
+ private final CharSequence mMetadata;
+ @Nullable
private ViewGroup mContentPreview = null;
FileContentPreviewUi(
int fileCount,
ChooserContentPreviewUi.ActionFactory actionFactory,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata
+ ) {
mFileCount = fileCount;
mActionFactory = actionFactory;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
}
@Override
@@ -67,18 +72,25 @@ class FileContentPreviewUi extends ContentPreviewUi {
}
@Override
- public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
- ViewGroup layout = displayInternal(resources, layoutInflater, parent);
- displayModifyShareAction(layout, mActionFactory);
- return layout;
+ public ViewGroup display(
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
+ return displayInternal(resources, layoutInflater, parent, headlineViewParent);
}
private ViewGroup displayInternal(
- Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
mContentPreview = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_file, parent, false);
+ inflateHeadline(headlineViewParent);
- displayHeadline(mContentPreview, mHeadlineGenerator.getFilesHeadline(mFileCount));
+ displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount));
+ displayMetadata(headlineViewParent, mMetadata);
if (mFileCount == 0) {
mContentPreview.setVisibility(View.GONE);
diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt
index fe35365b..16a948df 100644
--- a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt
+++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt
@@ -22,8 +22,11 @@ class FileInfo private constructor(val uri: Uri, val previewUri: Uri?, val mimeT
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
class Builder(val uri: Uri) {
var previewUri: Uri? = null
+ @Synchronized get
private set
+
var mimeType: String? = null
+ @Synchronized get
private set
@Synchronized fun withPreviewUri(uri: Uri?): Builder = apply { previewUri = uri }
diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
index 6e1212e9..da701ec4 100644
--- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
@@ -23,6 +23,7 @@ import android.content.res.Resources;
import android.net.Uri;
import android.text.util.Linkify;
import android.util.PluralsMessageFormatter;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -31,12 +32,13 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
-import androidx.lifecycle.Lifecycle;
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
+import kotlinx.coroutines.CoroutineScope;
+
import java.util.HashMap;
import java.util.List;
import java.util.function.Consumer;
@@ -48,7 +50,7 @@ import java.util.function.Consumer;
* file content).
*/
class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
- private final Lifecycle mLifecycle;
+ private final CoroutineScope mScope;
@Nullable
private final String mIntentMimeType;
private final CharSequence mText;
@@ -56,19 +58,22 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
private final ImageLoader mImageLoader;
private final MimeTypeClassifier mTypeClassifier;
private final HeadlineGenerator mHeadlineGenerator;
+ @Nullable
+ private final CharSequence mMetadata;
private final boolean mIsSingleImage;
private final int mFileCount;
+ private final boolean mAllowTextToggle;
private ViewGroup mContentPreviewView;
+ private View mHeadliveView;
private boolean mIsMetadataUpdated = false;
@Nullable
private Uri mFirstFilePreviewUri;
private boolean mAllImages;
private boolean mAllVideos;
- // TODO(b/285309527): make this a flag
- private static final boolean SHOW_TOGGLE_CHECKMARK = false;
+ private int mPreviewSize;
FilesPlusTextContentPreviewUi(
- Lifecycle lifecycle,
+ CoroutineScope scope,
boolean isSingleImage,
int fileCount,
CharSequence text,
@@ -76,12 +81,14 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
MimeTypeClassifier typeClassifier,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata,
+ boolean allowTextToggle) {
if (isSingleImage && fileCount != 1) {
throw new IllegalArgumentException(
"fileCount = " + fileCount + " and isSingleImage = true");
}
- mLifecycle = lifecycle;
+ mScope = scope;
mIntentMimeType = intentMimeType;
mFileCount = fileCount;
mIsSingleImage = isSingleImage;
@@ -90,6 +97,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
mImageLoader = imageLoader;
mTypeClassifier = typeClassifier;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
+ mAllowTextToggle = allowTextToggle;
}
@Override
@@ -98,10 +107,13 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
}
@Override
- public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
- ViewGroup layout = displayInternal(layoutInflater, parent);
- displayModifyShareAction(layout, mActionFactory);
- return layout;
+ public ViewGroup display(
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size);
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
public void updatePreviewMetadata(List<FileInfo> files) {
@@ -118,13 +130,18 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
mFirstFilePreviewUri = files.isEmpty() ? null : files.get(0).getPreviewUri();
mIsMetadataUpdated = true;
if (mContentPreviewView != null) {
- updateUiWithMetadata(mContentPreviewView);
+ updateUiWithMetadata(mContentPreviewView, mHeadliveView);
}
}
- private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) {
+ private ViewGroup displayInternal(
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
mContentPreviewView = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_files_text, parent, false);
+ mHeadliveView = headlineViewParent;
+ inflateHeadline(mHeadliveView);
final ActionRow actionRow =
mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row);
@@ -134,12 +151,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
if (!mIsSingleImage) {
mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE);
}
- prepareTextPreview(mContentPreviewView, mActionFactory);
+ prepareTextPreview(mContentPreviewView, mHeadliveView, mActionFactory);
if (mIsMetadataUpdated) {
- updateUiWithMetadata(mContentPreviewView);
+ updateUiWithMetadata(mContentPreviewView, mHeadliveView);
} else {
updateHeadline(
- mContentPreviewView,
+ mHeadliveView,
mFileCount,
mTypeClassifier.isImageType(mIntentMimeType),
mTypeClassifier.isVideoType(mIntentMimeType));
@@ -148,14 +165,15 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
return mContentPreviewView;
}
- private void updateUiWithMetadata(ViewGroup contentPreviewView) {
- updateHeadline(contentPreviewView, mFileCount, mAllImages, mAllVideos);
-
+ private void updateUiWithMetadata(ViewGroup contentPreviewView, View headlineView) {
+ prepareTextPreview(contentPreviewView, headlineView, mActionFactory);
+ updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos);
ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view);
if (mIsSingleImage && mFirstFilePreviewUri != null) {
mImageLoader.loadImage(
- mLifecycle,
+ mScope,
mFirstFilePreviewUri,
+ new Size(mPreviewSize, mPreviewSize),
bitmap -> {
if (bitmap == null) {
imagePreview.setVisibility(View.GONE);
@@ -169,8 +187,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
}
private void updateHeadline(
- ViewGroup contentPreview, int fileCount, boolean allImages, boolean allVideos) {
- CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action);
+ View headlineView, int fileCount, boolean allImages, boolean allVideos) {
+ CheckBox includeText = headlineView.requireViewById(R.id.include_text_action);
String headline;
if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) {
if (allImages) {
@@ -190,14 +208,16 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
}
}
- displayHeadline(contentPreview, headline);
+ displayHeadline(headlineView, headline);
+ displayMetadata(headlineView, mMetadata);
}
private void prepareTextPreview(
ViewGroup contentPreview,
+ View headlineView,
ChooserContentPreviewUi.ActionFactory actionFactory) {
final TextView textView = contentPreview.requireViewById(R.id.content_preview_text);
- CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action);
+ CheckBox includeText = headlineView.requireViewById(R.id.include_text_action);
boolean isLink = HttpUriMatcher.isHttpUri(mText.toString());
textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0);
textView.setText(mText);
@@ -213,9 +233,9 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
textView.setText(getNoTextString(contentPreview.getResources()));
}
shareTextAction.accept(!isChecked);
- updateHeadline(contentPreview, mFileCount, mAllImages, mAllVideos);
+ updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos);
});
- if (SHOW_TOGGLE_CHECKMARK) {
+ if (mAllowTextToggle) {
includeText.setVisibility(View.VISIBLE);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
index 5f87c924..059ee083 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
@@ -17,12 +17,14 @@
package com.android.intentresolver.contentpreview
/**
- * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief
- * description of the content being shared.
+ * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description
+ * of the content being shared.
*/
interface HeadlineGenerator {
fun getTextHeadline(text: CharSequence): String
+ fun getAlbumHeadline(): String
+
fun getImagesWithTextHeadline(text: CharSequence, count: Int): String
fun getVideosWithTextHeadline(text: CharSequence, count: Int): String
@@ -34,4 +36,6 @@ interface HeadlineGenerator {
fun getVideosHeadline(count: Int): String
fun getFilesHeadline(count: Int): String
+
+ fun getNotItemsSelectedHeadline(): String
}
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
index 1aace8c3..822d3097 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
@@ -16,36 +16,69 @@
package com.android.intentresolver.contentpreview
-import android.annotation.StringRes
import android.content.Context
-import com.android.intentresolver.R
import android.util.PluralsMessageFormatter
+import androidx.annotation.StringRes
+import com.android.intentresolver.R
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Inject
private const val PLURALS_COUNT = "count"
/**
- * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief
- * description of the content being shared.
+ * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description
+ * of the content being shared.
*/
-class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator {
+class HeadlineGeneratorImpl
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+) : HeadlineGenerator {
override fun getTextHeadline(text: CharSequence): String {
return context.getString(
- getTemplateResource(text, R.string.sharing_link, R.string.sharing_text))
+ getTemplateResource(text, R.string.sharing_link, R.string.sharing_text)
+ )
+ }
+
+ override fun getAlbumHeadline(): String {
+ return context.getString(R.string.sharing_album)
}
override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String {
- return getPluralString(getTemplateResource(
- text, R.string.sharing_images_with_link, R.string.sharing_images_with_text), count)
+ return getPluralString(
+ getTemplateResource(
+ text,
+ R.string.sharing_images_with_link,
+ R.string.sharing_images_with_text
+ ),
+ count
+ )
}
override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String {
- return getPluralString(getTemplateResource(
- text, R.string.sharing_videos_with_link, R.string.sharing_videos_with_text), count)
+ return getPluralString(
+ getTemplateResource(
+ text,
+ R.string.sharing_videos_with_link,
+ R.string.sharing_videos_with_text
+ ),
+ count
+ )
}
override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String {
- return getPluralString(getTemplateResource(
- text, R.string.sharing_files_with_link, R.string.sharing_files_with_text), count)
+ return getPluralString(
+ getTemplateResource(
+ text,
+ R.string.sharing_files_with_link,
+ R.string.sharing_files_with_text
+ ),
+ count
+ )
}
override fun getImagesHeadline(count: Int): String {
@@ -60,6 +93,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator {
return getPluralString(R.string.sharing_files, count)
}
+ override fun getNotItemsSelectedHeadline(): String =
+ context.getString(R.string.select_items_to_share)
+
private fun getPluralString(@StringRes templateResource: Int, count: Int): String {
return PluralsMessageFormatter.format(
context.resources,
@@ -70,8 +106,16 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator {
@StringRes
private fun getTemplateResource(
- text: CharSequence, @StringRes linkResource: Int, @StringRes nonLinkResource: Int
+ text: CharSequence,
+ @StringRes linkResource: Int,
+ @StringRes nonLinkResource: Int
): Int {
return if (text.toString().isHttpUri()) linkResource else nonLinkResource
}
}
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface HeadlineGeneratorModule {
+ @Binds fun bind(impl: HeadlineGeneratorImpl): HeadlineGenerator
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
index 8d0fb84b..ac34f552 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
@@ -18,25 +18,39 @@ package com.android.intentresolver.contentpreview
import android.graphics.Bitmap
import android.net.Uri
-import androidx.lifecycle.Lifecycle
+import android.util.Size
import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
/** A content preview image loader. */
-interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? {
+interface ImageLoader : suspend (Uri, Size) -> Bitmap?, suspend (Uri, Size, Boolean) -> Bitmap? {
/**
* Load preview image asynchronously; caching is allowed.
*
* @param uri content URI
+ * @param size target bitmap size
* @param callback a callback that will be invoked with the loaded image or null if loading has
* failed.
*/
- fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>)
+ fun loadImage(callerScope: CoroutineScope, uri: Uri, size: Size, callback: Consumer<Bitmap?>) {
+ callerScope.launch {
+ val bitmap = invoke(uri, size)
+ if (isActive) {
+ callback.accept(bitmap)
+ }
+ }
+ }
/** Prepopulate the image loader cache. */
- fun prePopulate(uris: List<Uri>)
+ fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>)
+
+ /** Returns a bitmap for the given URI if it's already cached, otherwise null */
+ fun getCachedBitmap(uri: Uri): Bitmap? = null
/** Load preview image; caching is allowed. */
- override suspend fun invoke(uri: Uri) = invoke(uri, true)
+ override suspend fun invoke(uri: Uri, size: Size) = invoke(uri, size, true)
/**
* Load preview image.
@@ -44,5 +58,5 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm
* @param uri content URI
* @param caching indicates if the loaded image could be cached.
*/
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap?
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap?
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
new file mode 100644
index 00000000..7cc4458f
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.content.res.Resources
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+interface ImageLoaderModule {
+ @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader
+
+ @Binds fun imageLoader(previewImageLoader: PreviewImageLoader): ImageLoader
+
+ companion object {
+ @Provides
+ @ThumbnailSize
+ fun thumbnailSize(@ApplicationOwned resources: Resources): Int =
+ resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)
+
+ @Provides @PreviewCacheSize fun cacheSize() = 16
+
+ @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
deleted file mode 100644
index 22dd1125..00000000
--- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
+++ /dev/null
@@ -1,156 +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.intentresolver.contentpreview
-
-import android.content.ContentResolver
-import android.graphics.Bitmap
-import android.net.Uri
-import android.util.Log
-import android.util.Size
-import androidx.annotation.GuardedBy
-import androidx.annotation.VisibleForTesting
-import androidx.collection.LruCache
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import java.util.function.Consumer
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Semaphore
-
-private const val TAG = "ImagePreviewImageLoader"
-
-/**
- * Implements preview image loading for the content preview UI. Provides requests deduplication,
- * image caching, and a limit on the number of parallel loadings.
- */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
-class ImagePreviewImageLoader
-@VisibleForTesting
-constructor(
- private val scope: CoroutineScope,
- thumbnailSize: Int,
- private val contentResolver: ContentResolver,
- cacheSize: Int,
- // TODO: consider providing a scope with the dispatcher configured with
- // [CoroutineDispatcher#limitedParallelism] instead
- private val contentResolverSemaphore: Semaphore,
-) : ImageLoader {
-
- constructor(
- scope: CoroutineScope,
- thumbnailSize: Int,
- contentResolver: ContentResolver,
- cacheSize: Int,
- maxSimultaneousRequests: Int = 4
- ) : this(scope, thumbnailSize, contentResolver, cacheSize, Semaphore(maxSimultaneousRequests))
-
- private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize)
-
- private val lock = Any()
- @GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize)
- @GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>()
-
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching)
-
- override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) {
- callerLifecycle.coroutineScope.launch {
- val image = loadImageAsync(uri, caching = true)
- if (isActive) {
- callback.accept(image)
- }
- }
- }
-
- override fun prePopulate(uris: List<Uri>) {
- uris.asSequence().take(cache.maxSize()).forEach { uri ->
- scope.launch { loadImageAsync(uri, caching = true) }
- }
- }
-
- private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? {
- return getRequestDeferred(uri, caching).await()
- }
-
- private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred<Bitmap?> {
- var shouldLaunchImageLoading = false
- val request =
- synchronized(lock) {
- cache[uri]
- ?: runningRequests
- .getOrPut(uri) {
- shouldLaunchImageLoading = true
- RequestRecord(uri, CompletableDeferred(), caching)
- }
- .apply { this.caching = this.caching || caching }
- }
- if (shouldLaunchImageLoading) {
- request.loadBitmapAsync()
- }
- return request.deferred
- }
-
- private fun RequestRecord.loadBitmapAsync() {
- scope
- .launch { loadBitmap() }
- .invokeOnCompletion { cause ->
- if (cause is CancellationException) {
- cancel()
- }
- }
- }
-
- private suspend fun RequestRecord.loadBitmap() {
- contentResolverSemaphore.acquire()
- val bitmap =
- try {
- contentResolver.loadThumbnail(uri, thumbnailSize, null)
- } catch (t: Throwable) {
- Log.d(TAG, "failed to load $uri preview", t)
- null
- } finally {
- contentResolverSemaphore.release()
- }
- complete(bitmap)
- }
-
- private fun RequestRecord.cancel() {
- synchronized(lock) {
- runningRequests.remove(uri)
- deferred.cancel()
- }
- }
-
- private fun RequestRecord.complete(bitmap: Bitmap?) {
- deferred.complete(bitmap)
- synchronized(lock) {
- runningRequests.remove(uri)
- if (bitmap != null && caching) {
- cache.put(uri, this)
- }
- }
- }
-
- private class RequestRecord(
- val uri: Uri,
- val deferred: CompletableDeferred<Bitmap?>,
- @GuardedBy("lock") var caching: Boolean
- )
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
index 80232537..ac002ab6 100644
--- a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
+++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
@@ -15,13 +15,16 @@
*/
@file:JvmName("HttpUriMatcher")
+
package com.android.intentresolver.contentpreview
import java.net.URI
internal fun String.isHttpUri() =
- kotlin.runCatching {
- URI(this).scheme.takeIf { scheme ->
- "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0
+ kotlin
+ .runCatching {
+ URI(this).scheme.takeIf { scheme ->
+ "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0
+ }
}
- }.getOrNull() != null
+ .getOrNull() != null
diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
index 90016932..924e6499 100644
--- a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
+++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
@@ -19,13 +19,17 @@ package com.android.intentresolver.contentpreview
import android.content.res.Resources
import android.util.Log
import android.view.LayoutInflater
+import android.view.View
import android.view.ViewGroup
internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() {
override fun getType(): Int = type
override fun display(
- resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?
+ resources: Resources?,
+ layoutInflater: LayoutInflater?,
+ parent: ViewGroup?,
+ headlineViewParent: View,
): ViewGroup? {
Log.e(TAG, "Unexpected content preview type: $type")
return null
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
index 9f1cc6c1..d7b9077d 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
@@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview
import android.content.ContentInterface
import android.content.Intent
-import android.database.Cursor
import android.media.MediaMetadata
import android.net.Uri
import android.provider.DocumentsContract
@@ -29,10 +28,10 @@ import android.text.TextUtils
import android.util.Log
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
+import com.android.intentresolver.Flags.individualMetadataTitleRead
import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE
import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE
+import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION
import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT
import com.android.intentresolver.measurements.runTracing
import com.android.intentresolver.util.ownedByCurrentUser
@@ -56,14 +55,19 @@ import kotlinx.coroutines.withTimeoutOrNull
* A set of metadata columns we read for a content URI (see
* [PreviewDataProvider.UriRecord.readQueryResult] method).
*/
-@VisibleForTesting
-val METADATA_COLUMNS =
+private val METADATA_COLUMNS =
arrayOf(
DocumentsContract.Document.COLUMN_FLAGS,
MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI,
OpenableColumns.DISPLAY_NAME,
- Downloads.Impl.COLUMN_TITLE
+ Downloads.Impl.COLUMN_TITLE,
)
+
+/** Preview-related metadata columns. */
+@VisibleForTesting
+val ICON_METADATA_COLUMNS =
+ arrayOf(DocumentsContract.Document.COLUMN_FLAGS, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)
+
private const val TIMEOUT_MS = 1_000L
/**
@@ -76,6 +80,7 @@ open class PreviewDataProvider
constructor(
private val scope: CoroutineScope,
private val targetIntent: Intent,
+ private val additionalContentUri: Uri?,
private val contentResolver: ContentInterface,
private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
) {
@@ -102,6 +107,9 @@ constructor(
open val uriCount: Int
get() = records.size
+ val uris: List<Uri>
+ get() = records.map { it.uri }
+
/**
* Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and
* [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
@@ -124,6 +132,9 @@ constructor(
* IMAGE, FILE, TEXT. */
if (!targetIntent.isSend || records.isEmpty()) {
CONTENT_PREVIEW_TEXT
+ } else if (shouldShowPayloadSelection()) {
+ // TODO: replace with the proper flags injection
+ CONTENT_PREVIEW_PAYLOAD_SELECTION
} else {
try {
runBlocking(scope.coroutineContext) {
@@ -134,7 +145,7 @@ constructor(
Log.w(
ContentPreviewUi.TAG,
"An attempt to read preview type from a cancelled scope",
- e
+ e,
)
CONTENT_PREVIEW_FILE
}
@@ -142,6 +153,22 @@ constructor(
}
}
+ private fun shouldShowPayloadSelection(): Boolean {
+ val extraContentUri = additionalContentUri ?: return false
+ return runCatching {
+ val authority = extraContentUri.authority
+ records.firstOrNull { authority == it.uri.authority } == null
+ }
+ .onFailure {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "Failed to check URI authorities; no payload toggling",
+ it,
+ )
+ }
+ .getOrDefault(false)
+ }
+
/**
* The first shared URI's metadata. This call wait's for the data to be loaded and falls back to
* a crude value if the data is not loaded within a time limit.
@@ -160,7 +187,7 @@ constructor(
Log.w(
ContentPreviewUi.TAG,
"An attempt to read first file info from a cancelled scope",
- e
+ e,
)
}
builder.build()
@@ -185,18 +212,24 @@ constructor(
* is not provided, derived from the URI.
*/
@Throws(IndexOutOfBoundsException::class)
- fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer<String>) {
+ fun getFirstFileName(callerScope: CoroutineScope, callback: Consumer<String>) {
if (records.isEmpty()) {
throw IndexOutOfBoundsException("There are no shared URIs")
}
- callerLifecycle.coroutineScope.launch {
- val result = scope.async { getFirstFileName() }.await()
- callback.accept(result)
- }
+ callerScope.launch { callback.accept(getFirstFileName()) }
+ }
+
+ /**
+ * Returns a title for the first shared URI which is read from URI metadata or, if the metadata
+ * is not provided, derived from the URI.
+ */
+ @Throws(IndexOutOfBoundsException::class)
+ suspend fun getFirstFileName(): String {
+ return scope.async { getFirstFileNameInternal() }.await()
}
@Throws(IndexOutOfBoundsException::class)
- private fun getFirstFileName(): String {
+ private fun getFirstFileNameInternal(): String {
if (records.isEmpty()) throw IndexOutOfBoundsException("There are no shared URIs")
val record = records[0]
@@ -251,63 +284,85 @@ constructor(
val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) }
val isImageType: Boolean
get() = typeClassifier.isImageType(mimeType)
+
val supportsImageType: Boolean by lazy {
- contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) !=
- null
+ contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null
}
val supportsThumbnail: Boolean
get() = query.supportsThumbnail
+
val title: String
- get() = query.title
+ get() = if (individualMetadataTitleRead()) titleFromQuery else query.title
+
val iconUri: Uri?
get() = query.iconUri
- private val query by lazy { readQueryResult() }
-
- private fun readQueryResult(): QueryResult {
- val cursor =
- contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult()
-
- var flagColIdx = -1
- var displayIconUriColIdx = -1
- var nameColIndex = -1
- var titleColIndex = -1
- // TODO: double-check why Cursor#getColumnInded didn't work
- cursor.columnNames.forEachIndexed { i, columnName ->
- when (columnName) {
- DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i
- MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i
- OpenableColumns.DISPLAY_NAME -> nameColIndex = i
- Downloads.Impl.COLUMN_TITLE -> titleColIndex = i
- }
- }
+ private val query by lazy {
+ readQueryResult(
+ if (individualMetadataTitleRead()) ICON_METADATA_COLUMNS else METADATA_COLUMNS
+ )
+ }
+
+ private val titleFromQuery by lazy {
+ readDisplayNameFromQuery().takeIf { !TextUtils.isEmpty(it) } ?: readTitleFromQuery()
+ }
- val supportsThumbnail =
- flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
+ private fun readQueryResult(columns: Array<String>): QueryResult =
+ contentResolver.querySafe(uri, columns)?.use { cursor ->
+ if (!cursor.moveToFirst()) return@use null
+
+ var flagColIdx = -1
+ var displayIconUriColIdx = -1
+ var nameColIndex = -1
+ var titleColIndex = -1
+ // TODO: double-check why Cursor#getColumnInded didn't work
+ cursor.columnNames.forEachIndexed { i, columnName ->
+ when (columnName) {
+ DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i
+ MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i
+ OpenableColumns.DISPLAY_NAME -> nameColIndex = i
+ Downloads.Impl.COLUMN_TITLE -> titleColIndex = i
+ }
+ }
- var title = ""
- if (nameColIndex >= 0) {
- title = cursor.getString(nameColIndex) ?: ""
- }
- if (TextUtils.isEmpty(title) && titleColIndex >= 0) {
- title = cursor.getString(titleColIndex) ?: ""
- }
+ val supportsThumbnail =
+ flagColIdx >= 0 &&
+ ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
- val iconUri =
- if (displayIconUriColIdx >= 0) {
- cursor.getString(displayIconUriColIdx)?.let(Uri::parse)
- } else {
- null
+ var title = ""
+ if (nameColIndex >= 0) {
+ title = cursor.getString(nameColIndex) ?: ""
+ }
+ if (TextUtils.isEmpty(title) && titleColIndex >= 0) {
+ title = cursor.getString(titleColIndex) ?: ""
}
- return QueryResult(supportsThumbnail, title, iconUri)
- }
+ val iconUri =
+ if (displayIconUriColIdx >= 0) {
+ cursor.getString(displayIconUriColIdx)?.let(Uri::parse)
+ } else {
+ null
+ }
+
+ QueryResult(supportsThumbnail, title, iconUri)
+ } ?: QueryResult()
+
+ private fun readTitleFromQuery(): String = readStringColumn(Downloads.Impl.COLUMN_TITLE)
+
+ private fun readDisplayNameFromQuery(): String =
+ readStringColumn(OpenableColumns.DISPLAY_NAME)
+
+ private fun readStringColumn(column: String): String =
+ contentResolver.querySafe(uri, arrayOf(column))?.use { cursor ->
+ if (!cursor.moveToFirst()) return@use null
+ cursor.readString(column)
+ } ?: ""
}
private class QueryResult(
val supportsThumbnail: Boolean = false,
val title: String = "",
- val iconUri: Uri? = null
+ val iconUri: Uri? = null,
)
}
@@ -344,51 +399,3 @@ private fun getFileName(uri: Uri): String {
fileName.substring(index + 1)
}
}
-
-private fun ContentInterface.getTypeSafe(uri: Uri): String? =
- runTracing("getType") {
- try {
- getType(uri)
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "mime type")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
- null
- }
- }
-
-private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String>? =
- runTracing("getStreamTypes") {
- try {
- getStreamTypes(uri, "*/*")
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "stream types")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t)
- null
- }
- }
-
-private fun ContentInterface.querySafe(uri: Uri): Cursor? =
- runTracing("query") {
- try {
- query(uri, METADATA_COLUMNS, null, null)
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "metadata")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
- null
- }
- }
-
-private fun logProviderPermissionWarning(uri: Uri, dataName: String) {
- // The ContentResolver already logs the exception. Log something more informative.
- Log.w(
- ContentPreviewUi.TAG,
- "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" +
- " ensure that the sharesheet is given permission."
- )
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt
new file mode 100644
index 00000000..1dc497b3
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Log
+import android.util.Size
+import androidx.collection.lruCache
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ViewModelOwned
+import javax.annotation.concurrent.GuardedBy
+import javax.inject.Inject
+import javax.inject.Qualifier
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+
+private const val TAG = "PayloadSelImageLoader"
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewCacheSize
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewMaxConcurrency
+
+/**
+ * Implements preview image loading for the payload selection UI. Cancels preview loading for items
+ * that has been evicted from the cache at the expense of a possible request duplication (deemed
+ * unlikely).
+ */
+class PreviewImageLoader
+@Inject
+constructor(
+ @ViewModelOwned private val scope: CoroutineScope,
+ @PreviewCacheSize private val cacheSize: Int,
+ @ThumbnailSize private val defaultPreviewSize: Int,
+ private val thumbnailLoader: ThumbnailLoader,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4,
+) : ImageLoader {
+
+ private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests)
+
+ private val lock = Any()
+ @GuardedBy("lock") private val runningRequests = hashMapOf<Uri, RequestRecord>()
+ @GuardedBy("lock")
+ private val cache =
+ lruCache<Uri, RequestRecord>(
+ maxSize = cacheSize,
+ onEntryRemoved = { _, _, oldRec, newRec ->
+ if (oldRec !== newRec) {
+ onRecordEvictedFromCache(oldRec)
+ }
+ },
+ )
+
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? =
+ loadImageInternal(uri, size, caching)
+
+ override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
+ uriSizePairs.asSequence().take(cacheSize).forEach { uri ->
+ scope.launch { loadImageInternal(uri.first, uri.second, caching = true) }
+ }
+ }
+
+ private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? {
+ return withRequestRecord(uri, caching) { record ->
+ val newSize = sanitize(size)
+ val newMetric = newSize.metric
+ record
+ .also {
+ // set the requested size to the max of the new and the previous value; input
+ // will emit if the resulted value is greater than the old one
+ it.input.update { oldSize ->
+ if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize
+ }
+ }
+ .output
+ // filter out bitmaps of a lower resolution than that we're requesting
+ .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric }
+ .firstOrNull()
+ ?.let { (it as BitmapLoadingState.Loaded).bitmap }
+ }
+ }
+
+ private suspend fun withRequestRecord(
+ uri: Uri,
+ caching: Boolean,
+ block: suspend (RequestRecord) -> Bitmap?,
+ ): Bitmap? {
+ val record = trackRecordRunning(uri, caching)
+ return try {
+ block(record)
+ } finally {
+ untrackRecordRunning(uri, record)
+ }
+ }
+
+ private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord =
+ synchronized(lock) {
+ runningRequests
+ .getOrPut(uri) { cache[uri] ?: createRecord(uri) }
+ .also { record ->
+ record.clientCount++
+ if (caching) {
+ cache.put(uri, record)
+ }
+ }
+ }
+
+ private fun untrackRecordRunning(uri: Uri, record: RequestRecord) {
+ synchronized(lock) {
+ record.clientCount--
+ if (record.clientCount <= 0) {
+ runningRequests.remove(uri)
+ val result = record.output.value
+ if (cache[uri] == null) {
+ record.loadingJob.cancel()
+ } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) {
+ cache.remove(uri)
+ }
+ }
+ }
+ }
+
+ private fun onRecordEvictedFromCache(record: RequestRecord) {
+ synchronized(lock) {
+ if (record.clientCount <= 0) {
+ record.loadingJob.cancel()
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun createRecord(uri: Uri): RequestRecord {
+ // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous
+ val input = MutableStateFlow<Size?>(null)
+ val output = MutableStateFlow<BitmapLoadingState>(BitmapLoadingState.Loading)
+ val job =
+ scope.launch(bgDispatcher) {
+ // the image loading pipeline: input -- a desired image size, output -- a bitmap
+ input
+ .filterNotNull()
+ .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) }
+ .collect { output.tryEmit(it) }
+ }
+ return RequestRecord(input, output, job, clientCount = 0)
+ }
+
+ private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? =
+ contentResolverSemaphore.withPermit {
+ runCatching { thumbnailLoader.loadThumbnail(uri, size) }
+ .onFailure { Log.d(TAG, "failed to load $uri preview", it) }
+ .getOrNull()
+ }
+
+ private class RequestRecord(
+ /** The image loading pipeline input: desired preview size */
+ val input: MutableStateFlow<Size?>,
+ /** The image loading pipeline output */
+ val output: MutableStateFlow<BitmapLoadingState>,
+ /** The image loading pipeline job */
+ val loadingJob: Job,
+ @GuardedBy("lock") var clientCount: Int,
+ )
+
+ private sealed interface BitmapLoadingState {
+ data object Loading : BitmapLoadingState
+
+ data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState
+ }
+
+ private fun sanitize(size: Size?): Size =
+ size?.takeIf { it.width > 0 && it.height > 0 }
+ ?: Size(defaultPreviewSize, defaultPreviewSize)
+}
+
+private val Size.metric
+ get() = maxOf(width, height)
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
deleted file mode 100644
index 6013f5a0..00000000
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ /dev/null
@@ -1,76 +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.intentresolver.contentpreview
-
-import android.app.Application
-import androidx.annotation.MainThread
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.CreationExtras
-import com.android.intentresolver.ChooserRequestParameters
-import com.android.intentresolver.R
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.plus
-
-/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */
-class PreviewViewModel(
- private val application: Application,
- private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
-) : BasePreviewViewModel() {
- private var previewDataProvider: PreviewDataProvider? = null
- private var imageLoader: ImagePreviewImageLoader? = null
-
- @MainThread
- override fun createOrReuseProvider(
- chooserRequest: ChooserRequestParameters
- ): PreviewDataProvider =
- previewDataProvider
- ?: PreviewDataProvider(
- viewModelScope + dispatcher,
- chooserRequest.targetIntent,
- application.contentResolver
- )
- .also { previewDataProvider = it }
-
- @MainThread
- override fun createOrReuseImageLoader(): ImageLoader =
- imageLoader
- ?: ImagePreviewImageLoader(
- viewModelScope + dispatcher,
- thumbnailSize =
- application.resources.getDimensionPixelSize(
- R.dimen.chooser_preview_image_max_dimen
- ),
- application.contentResolver,
- cacheSize = 16
- )
- .also { imageLoader = it }
-
- companion object {
- val Factory: ViewModelProvider.Factory =
- object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun <T : ViewModel> create(
- modelClass: Class<T>,
- extras: CreationExtras
- ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
new file mode 100644
index 00000000..ff52556a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview
+
+import android.content.res.Resources
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.annotation.VisibleForTesting
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
+import com.android.intentresolver.ui.viewmodel.ChooserViewModel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+class ShareouselContentPreviewUi : ContentPreviewUi() {
+
+ override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION
+
+ override fun display(
+ resources: Resources,
+ layoutInflater: LayoutInflater,
+ parent: ViewGroup,
+ headlineViewParent: View,
+ ): ViewGroup = displayInternal(parent, headlineViewParent)
+
+ private fun displayInternal(parent: ViewGroup, headlineViewParent: View): ViewGroup {
+ inflateHeadline(headlineViewParent)
+ return ComposeView(parent.context).apply {
+ setContent {
+ val vm: ChooserViewModel = viewModel()
+ val viewModel: ShareouselViewModel = vm.shareouselViewModel
+
+ LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) }
+
+ MaterialTheme(
+ colorScheme =
+ if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ },
+ ) {
+ Shareousel(viewModel)
+ }
+ }
+ }
+ }
+
+ private suspend fun bindHeader(viewModel: ShareouselViewModel, headlineViewParent: View) {
+ coroutineScope {
+ launch { bindHeadline(viewModel, headlineViewParent) }
+ launch { bindMetadataText(viewModel, headlineViewParent) }
+ }
+ }
+
+ private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) {
+ viewModel.headline.collect { headline ->
+ headlineViewParent.findViewById<TextView>(R.id.headline)?.apply {
+ if (headline.isNotBlank()) {
+ text = headline
+ visibility = View.VISIBLE
+ } else {
+ visibility = View.GONE
+ }
+ }
+ }
+ }
+
+ private suspend fun bindMetadataText(viewModel: ShareouselViewModel, headlineViewParent: View) {
+ viewModel.metadataText.collect { metadata ->
+ headlineViewParent.findViewById<TextView>(R.id.metadata)?.apply {
+ if (metadata?.isNotBlank() == true) {
+ text = metadata
+ visibility = View.VISIBLE
+ } else {
+ visibility = View.GONE
+ }
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
index c38ed03a..45a0130d 100644
--- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -20,7 +20,9 @@ import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser;
import android.content.res.Resources;
import android.net.Uri;
+import android.text.SpannableStringBuilder;
import android.text.TextUtils;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -28,38 +30,50 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
-import androidx.lifecycle.Lifecycle;
+import androidx.core.view.ViewCompat;
+import com.android.intentresolver.ContentTypeHint;
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ViewRoleDescriptionAccessibilityDelegate;
+
+import kotlinx.coroutines.CoroutineScope;
class TextContentPreviewUi extends ContentPreviewUi {
- private final Lifecycle mLifecycle;
+ private final CoroutineScope mScope;
@Nullable
private final CharSequence mSharingText;
@Nullable
private final CharSequence mPreviewTitle;
@Nullable
+ private final CharSequence mMetadata;
+ @Nullable
private final Uri mPreviewThumbnail;
private final ImageLoader mImageLoader;
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final HeadlineGenerator mHeadlineGenerator;
+ private final ContentTypeHint mContentTypeHint;
+ private int mPreviewSize;
TextContentPreviewUi(
- Lifecycle lifecycle,
+ CoroutineScope scope,
@Nullable CharSequence sharingText,
@Nullable CharSequence previewTitle,
+ @Nullable CharSequence metadata,
@Nullable Uri previewThumbnail,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
- HeadlineGenerator headlineGenerator) {
- mLifecycle = lifecycle;
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint) {
+ mScope = scope;
mSharingText = sharingText;
mPreviewTitle = previewTitle;
+ mMetadata = metadata;
mPreviewThumbnail = previewThumbnail;
mImageLoader = imageLoader;
mActionFactory = actionFactory;
mHeadlineGenerator = headlineGenerator;
+ mContentTypeHint = contentTypeHint;
}
@Override
@@ -68,17 +82,22 @@ class TextContentPreviewUi extends ContentPreviewUi {
}
@Override
- public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
- ViewGroup layout = displayInternal(layoutInflater, parent);
- displayModifyShareAction(layout, mActionFactory);
- return layout;
+ public ViewGroup display(
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size);
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
private ViewGroup displayInternal(
LayoutInflater layoutInflater,
- ViewGroup parent) {
+ ViewGroup parent,
+ View headlineViewParent) {
ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_text, parent, false);
+ inflateHeadline(headlineViewParent);
final ActionRow actionRow =
contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row);
@@ -93,13 +112,9 @@ class TextContentPreviewUi extends ContentPreviewUi {
TextView textView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_text);
- String text = mSharingText.toString();
- // If we're only previewing one line, then strip out newlines.
- if (textView.getMaxLines() == 1) {
- text = text.replace("\n", " ");
- }
- textView.setText(text);
+ textView.setText(
+ textView.getMaxLines() == 1 ? replaceLineBreaks(mSharingText) : mSharingText);
TextView previewTitleView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_title);
@@ -109,30 +124,55 @@ class TextContentPreviewUi extends ContentPreviewUi {
previewTitleView.setText(mPreviewTitle);
}
- ImageView previewThumbnailView = contentPreviewLayout.findViewById(
+ final ImageView previewThumbnailView = contentPreviewLayout.requireViewById(
com.android.internal.R.id.content_preview_thumbnail);
if (!isOwnedByCurrentUser(mPreviewThumbnail)) {
previewThumbnailView.setVisibility(View.GONE);
} else {
mImageLoader.loadImage(
- mLifecycle,
+ mScope,
mPreviewThumbnail,
+ new Size(mPreviewSize, mPreviewSize),
(bitmap) -> updateViewWithImage(
- contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail),
+ previewThumbnailView,
bitmap));
}
Runnable onCopy = mActionFactory.getCopyButtonRunnable();
View copyButton = contentPreviewLayout.findViewById(R.id.copy);
- if (onCopy != null) {
- copyButton.setOnClickListener((v) -> onCopy.run());
- } else {
- copyButton.setVisibility(View.GONE);
+ if (copyButton != null) {
+ if (onCopy != null) {
+ copyButton.setOnClickListener((v) -> onCopy.run());
+ ViewCompat.setAccessibilityDelegate(
+ copyButton,
+ new ViewRoleDescriptionAccessibilityDelegate(
+ layoutInflater.getContext()
+ .getString(R.string.role_description_button)));
+ } else {
+ copyButton.setVisibility(View.GONE);
+ }
}
- displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText));
+ String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM)
+ ? mHeadlineGenerator.getAlbumHeadline()
+ : mHeadlineGenerator.getTextHeadline(mSharingText);
+ displayHeadline(headlineViewParent, headlineText);
+ displayMetadata(headlineViewParent, mMetadata);
return contentPreviewLayout;
}
+
+ @Nullable
+ private static CharSequence replaceLineBreaks(@Nullable CharSequence text) {
+ if (text == null) {
+ return null;
+ }
+ SpannableStringBuilder string = new SpannableStringBuilder(text);
+ for (int i = 0, size = string.length(); i < size; i++) {
+ if (string.charAt(i) == '\n') {
+ string.replace(i, i + 1, " ");
+ }
+ }
+ return string;
+ }
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
new file mode 100644
index 00000000..e8afa480
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview
+
+import android.content.ContentResolver
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Size
+import com.android.intentresolver.util.withCancellationSignal
+import javax.inject.Inject
+
+/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */
+interface ThumbnailLoader {
+ /**
+ * Loads a thumbnail for the given [uri].
+ *
+ * The size of the thumbnail is determined by the implementation.
+ */
+ suspend fun loadThumbnail(uri: Uri): Bitmap?
+
+ /**
+ * Loads a thumbnail for the given [uri] and [size].
+ *
+ * The [size] is the size of the thumbnail in pixels.
+ */
+ suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap?
+}
+
+/** Default implementation of [ThumbnailLoader]. */
+class ThumbnailLoaderImpl
+@Inject
+constructor(
+ private val contentResolver: ContentResolver,
+ @ThumbnailSize thumbnailSize: Int,
+) : ThumbnailLoader {
+
+ private val size = Size(thumbnailSize, thumbnailSize)
+
+ override suspend fun loadThumbnail(uri: Uri): Bitmap =
+ contentResolver.loadThumbnail(uri, size, /* signal= */ null)
+
+ override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap =
+ withCancellationSignal { signal ->
+ contentResolver.loadThumbnail(uri, size, signal)
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 8e635aba..7de988c4 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -20,6 +20,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE
import android.content.res.Resources;
import android.util.Log;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -31,12 +32,14 @@ import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
-import java.util.List;
-import java.util.Objects;
+import kotlin.Pair;
import kotlinx.coroutines.CoroutineScope;
import kotlinx.coroutines.flow.Flow;
+import java.util.List;
+import java.util.Objects;
+
class UnifiedContentPreviewUi extends ContentPreviewUi {
private final boolean mShowEditAction;
@Nullable
@@ -46,12 +49,16 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
private final MimeTypeClassifier mTypeClassifier;
private final TransitionElementStatusCallback mTransitionElementStatusCallback;
private final HeadlineGenerator mHeadlineGenerator;
+ @Nullable
+ private final CharSequence mMetadata;
private final Flow<FileInfo> mFileInfoFlow;
private final int mItemCount;
@Nullable
private List<FileInfo> mFiles;
@Nullable
private ViewGroup mContentPreviewView;
+ private View mHeadlineView;
+ private int mPreviewSize;
UnifiedContentPreviewUi(
CoroutineScope scope,
@@ -63,7 +70,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
TransitionElementStatusCallback transitionElementStatusCallback,
Flow<FileInfo> fileInfoFlow,
int itemCount,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata) {
mShowEditAction = isSingleImage;
mIntentMimeType = intentMimeType;
mActionFactory = actionFactory;
@@ -73,6 +81,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
mFileInfoFlow = fileInfoFlow;
mItemCount = itemCount;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles);
}
@@ -83,26 +92,35 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
}
@Override
- public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
- ViewGroup layout = displayInternal(layoutInflater, parent);
- displayModifyShareAction(layout, mActionFactory);
- return layout;
+ public ViewGroup display(
+ Resources resources,
+ LayoutInflater layoutInflater,
+ ViewGroup parent,
+ View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen);
+ return displayInternal(layoutInflater, parent, headlineViewParent);
}
private void setFiles(List<FileInfo> files) {
- mImageLoader.prePopulate(files.stream()
- .map(FileInfo::getPreviewUri)
- .filter(Objects::nonNull)
- .toList());
+ Size previewSize = new Size(mPreviewSize, mPreviewSize);
+ mImageLoader.prePopulate(
+ files.stream()
+ .map(FileInfo::getPreviewUri)
+ .filter(Objects::nonNull)
+ .map((uri -> new Pair<>(uri, previewSize)))
+ .toList());
mFiles = files;
if (mContentPreviewView != null) {
- updatePreviewWithFiles(mContentPreviewView, files);
+ updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files);
}
}
- private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) {
+ private ViewGroup displayInternal(
+ LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) {
mContentPreviewView = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_image, parent, false);
+ mHeadlineView = headlineViewParent;
+ inflateHeadline(mHeadlineView);
final ActionRow actionRow =
mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row);
@@ -111,6 +129,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
ScrollableImagePreviewView imagePreview =
mContentPreviewView.requireViewById(R.id.scrollable_image_preview);
+ imagePreview.setPreviewHeight(mPreviewSize);
imagePreview.setImageLoader(mImageLoader);
imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE));
imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
@@ -122,10 +141,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
mItemCount);
if (mFiles != null) {
- updatePreviewWithFiles(mContentPreviewView, mFiles);
+ updatePreviewWithFiles(mContentPreviewView, mHeadlineView, mFiles);
} else {
displayHeadline(
- mContentPreviewView,
+ mHeadlineView,
mItemCount,
mTypeClassifier.isImageType(mIntentMimeType),
mTypeClassifier.isVideoType(mIntentMimeType));
@@ -135,7 +154,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
return mContentPreviewView;
}
- private void updatePreviewWithFiles(ViewGroup contentPreviewView, List<FileInfo> files) {
+ private void updatePreviewWithFiles(
+ ViewGroup contentPreviewView, View headlineView, List<FileInfo> files) {
final int count = files.size();
ScrollableImagePreviewView imagePreview =
contentPreviewView.requireViewById(R.id.scrollable_image_preview);
@@ -158,11 +178,11 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video;
}
- displayHeadline(contentPreviewView, count, allImages, allVideos);
+ displayHeadline(headlineView, count, allImages, allVideos);
}
private void displayHeadline(
- ViewGroup layout, int count, boolean allImages, boolean allVideos) {
+ View layout, int count, boolean allImages, boolean allVideos) {
if (allImages) {
displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count));
} else if (allVideos) {
@@ -170,5 +190,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
} else {
displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count));
}
+ displayMetadata(layout, mMetadata);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt
new file mode 100644
index 00000000..80d0e058
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.database.Cursor
+import android.media.MediaMetadata
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.util.Log
+import android.util.Size
+import com.android.intentresolver.measurements.runTracing
+
+internal fun ContentInterface.getTypeSafe(uri: Uri): String? =
+ runTracing("getType") {
+ try {
+ getType(uri)
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "mime type")
+ null
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
+ null
+ }
+ }
+
+internal fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String?> =
+ runTracing("getStreamTypes") {
+ try {
+ getStreamTypes(uri, "*/*") ?: emptyArray()
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "stream types")
+ emptyArray<String?>()
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t)
+ emptyArray<String?>()
+ }
+ }
+
+internal fun ContentInterface.querySafe(uri: Uri, columns: Array<String>): Cursor? =
+ runTracing("query") {
+ try {
+ query(uri, columns, null, null)
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "metadata")
+ null
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
+ null
+ }
+ }
+
+internal fun Cursor.readSupportsThumbnail(): Boolean =
+ runCatching {
+ val flagColIdx = columnNames.indexOf(DocumentsContract.Document.COLUMN_FLAGS)
+ flagColIdx >= 0 && ((getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
+ }
+ .getOrDefault(false)
+
+internal fun Cursor.readPreviewUri(): Uri? =
+ runCatching { readString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)?.let(Uri::parse) }
+ .getOrNull()
+
+fun Cursor.readSize(): Size? {
+ val widthIdx = columnNames.indexOf(WIDTH)
+ val heightIdx = columnNames.indexOf(HEIGHT)
+ return if (widthIdx < 0 || heightIdx < 0 || isNull(widthIdx) || isNull(heightIdx)) {
+ null
+ } else {
+ runCatching {
+ val width = getInt(widthIdx)
+ val height = getInt(heightIdx)
+ if (width >= 0 && height > 0) {
+ Size(width, height)
+ } else {
+ null
+ }
+ }
+ .getOrNull()
+ }
+}
+
+internal fun Cursor.readString(columnName: String): String? =
+ runCatching { columnNames.indexOf(columnName).takeIf { it >= 0 }?.let { getString(it) } }
+ .getOrNull()
+
+private fun logProviderPermissionWarning(uri: Uri, dataName: String) {
+ // The ContentResolver already logs the exception. Log something more informative.
+ Log.w(
+ ContentPreviewUi.TAG,
+ "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" +
+ " ensure that the sharesheet is given permission.",
+ )
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
new file mode 100644
index 00000000..4e403c22
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.media.MediaMetadata
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.MediaStore.MediaColumns
+import android.util.Size
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Inject
+
+fun interface UriMetadataReader {
+ fun getMetadata(uri: Uri): FileInfo
+ fun readPreviewSize(uri: Uri): Size? = null
+}
+
+class UriMetadataReaderImpl
+@Inject
+constructor(
+ private val contentResolver: ContentInterface,
+ private val typeClassifier: MimeTypeClassifier,
+) : UriMetadataReader {
+ override fun getMetadata(uri: Uri): FileInfo {
+ val builder = FileInfo.Builder(uri)
+ val mimeType = contentResolver.getTypeSafe(uri)
+ builder.withMimeType(mimeType)
+ if (
+ typeClassifier.isImageType(mimeType) ||
+ contentResolver.supportsImageType(uri) ||
+ contentResolver.supportsThumbnail(uri)
+ ) {
+ builder.withPreviewUri(uri)
+ return builder.build()
+ }
+ val previewUri = contentResolver.readPreviewUri(uri)
+ if (previewUri != null) {
+ builder.withPreviewUri(previewUri)
+ }
+ return builder.build()
+ }
+
+ override fun readPreviewSize(uri: Uri): Size? = contentResolver.readPreviewSize(uri)
+
+ private fun ContentInterface.supportsImageType(uri: Uri): Boolean =
+ getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null
+
+ private fun ContentInterface.supportsThumbnail(uri: Uri): Boolean =
+ querySafe(uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS))?.use { cursor ->
+ cursor.moveToFirst() && cursor.readSupportsThumbnail()
+ }
+ ?: false
+
+ private fun ContentInterface.readPreviewUri(uri: Uri): Uri? =
+ querySafe(uri, arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI))?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.readPreviewUri()
+ } else {
+ null
+ }
+ }
+
+ private fun ContentInterface.readPreviewSize(uri: Uri): Size? =
+ querySafe(uri, arrayOf(MediaColumns.WIDTH, MediaColumns.HEIGHT))?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.readSize()
+ } else {
+ null
+ }
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface UriMetadataReaderModule {
+
+ @Binds fun bind(impl: UriMetadataReaderImpl): UriMetadataReader
+
+ companion object {
+ @Provides fun classifier(): MimeTypeClassifier = DefaultMimeTypeClassifier
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt
new file mode 100644
index 00000000..b7945005
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.data.model
+
+import android.graphics.drawable.Icon
+
+/** Data model for a custom action the user can take. */
+data class CustomActionModel(
+ /** Label presented to the user identifying this action. */
+ val label: CharSequence,
+ /** Icon presented to the user for this action. */
+ val icon: Icon,
+ /** When invoked, performs this action. */
+ val performAction: () -> Unit,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt
new file mode 100644
index 00000000..c3bb88c8
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.data.repository
+
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** Tracks the result of the current activity. */
+@ActivityRetainedScoped
+class ActivityResultRepository @Inject constructor() {
+ /** The result of the current activity, or `null` if the activity is still active. */
+ val activityResult = MutableStateFlow<Int?>(null)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt
new file mode 100644
index 00000000..b104d4bf
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.data.repository
+
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * Stores previews for Shareousel UI that have been cached locally from a remote
+ * [android.database.Cursor].
+ */
+@ActivityRetainedScoped
+class CursorPreviewsRepository @Inject constructor() {
+ /** Previews available for display within Shareousel. */
+ val previewsModel = MutableStateFlow<PreviewsModel?>(null)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt
new file mode 100644
index 00000000..1745cd9c
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.data.repository
+
+import android.content.Intent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** Tracks active async communication with sharing app to notify of target intent update. */
+@ActivityRetainedScoped
+class PendingSelectionCallbackRepository @Inject constructor() {
+ /**
+ * The target [Intent] that is has an active update request with the sharing app, or `null` if
+ * there is no active request.
+ */
+ val pendingTargetIntent: MutableStateFlow<Intent?> = MutableStateFlow(null)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt
new file mode 100644
index 00000000..0688ce02
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.data.repository
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** Stores set of selected previews. */
+@ActivityRetainedScoped
+class PreviewSelectionsRepository @Inject constructor() {
+ val selections = MutableStateFlow(emptyMap<Uri, PreviewModel>())
+}
diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt
index 5b5d769c..3aa0d567 100644
--- a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,12 +14,11 @@
* limitations under the License.
*/
-package com.android.intentresolver.flags
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor
-import com.android.systemui.flags.ReleasedFlag
-import com.android.systemui.flags.UnreleasedFlag
+import com.android.intentresolver.util.cursor.CursorView
-interface FeatureFlagRepository {
- fun isEnabled(flag: UnreleasedFlag): Boolean
- fun isEnabled(flag: ReleasedFlag): Boolean
+/** Asynchronously retrieves a [CursorView]. */
+fun interface CursorResolver<out T> {
+ suspend fun getCursor(): CursorView<T>?
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
new file mode 100644
index 00000000..2b14cdea
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.cursor
+
+import android.content.ContentInterface
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.service.chooser.AdditionalContentContract.Columns.URI
+import androidx.core.os.bundleOf
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.contentpreview.readSize
+import com.android.intentresolver.inject.AdditionalContent
+import com.android.intentresolver.inject.ChooserIntent
+import com.android.intentresolver.util.cursor.CursorView
+import com.android.intentresolver.util.cursor.viewBy
+import com.android.intentresolver.util.withCancellationSignal
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import javax.inject.Inject
+import javax.inject.Qualifier
+
+/** [CursorResolver] for the [CursorView] underpinning Shareousel. */
+class PayloadToggleCursorResolver
+@Inject
+constructor(
+ private val contentResolver: ContentInterface,
+ @AdditionalContent private val cursorUri: Uri,
+ @ChooserIntent private val chooserIntent: Intent,
+) : CursorResolver<CursorRow?> {
+ override suspend fun getCursor(): CursorView<CursorRow?>? = withCancellationSignal { signal ->
+ runCatching {
+ contentResolver.query(
+ cursorUri,
+ arrayOf(URI, WIDTH, HEIGHT),
+ bundleOf(Intent.EXTRA_INTENT to chooserIntent),
+ signal,
+ )
+ }
+ .getOrNull()
+ ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize(), position) } }
+ }
+
+ private fun Cursor.readUri(): Uri? {
+ val uriIdx = columnNames.indexOf(URI)
+ if (uriIdx < 0) return null
+ return runCatching {
+ getString(uriIdx)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority }
+ }
+ .getOrNull()
+ }
+
+ @Module
+ @InstallIn(ViewModelComponent::class)
+ interface Binding {
+ @Binds
+ @PayloadToggle
+ fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<CursorRow?>
+ }
+}
+
+/** [CursorResolver] for the [CursorView] underpinning Shareousel. */
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PayloadToggle
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt
new file mode 100644
index 00000000..faad5bbf
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import android.app.ActivityOptions
+import android.app.PendingIntent
+import android.content.Context
+import com.android.intentresolver.R
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Inject
+import javax.inject.Qualifier
+
+/** [PendingIntentSender] for Shareousel custom actions. */
+class CustomActionPendingIntentSender
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+) : PendingIntentSender {
+ override fun send(pendingIntent: PendingIntent) {
+ pendingIntent.send(
+ /* context = */ null,
+ /* code = */ 0,
+ /* intent = */ null,
+ /* onFinished = */ null,
+ /* handler = */ null,
+ /* requiredPermission = */ null,
+ /* options = */ ActivityOptions.makeCustomAnimation(
+ context,
+ R.anim.slide_in_right,
+ R.anim.slide_out_left,
+ )
+ .toBundle()
+ )
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface Binding {
+ @Binds
+ @CustomAction
+ fun bindSender(sender: CustomActionPendingIntentSender): PendingIntentSender
+ }
+}
+
+/** [PendingIntentSender] for Shareousel custom actions. */
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class CustomAction
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt
new file mode 100644
index 00000000..d75884d5
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import android.app.PendingIntent
+import android.service.chooser.ChooserAction
+import android.util.Log
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object InitialCustomActionsModule {
+ @Provides
+ fun initialCustomActionModels(
+ chooserActions: List<ChooserAction>,
+ @CustomAction pendingIntentSender: PendingIntentSender,
+ ): List<CustomActionModel> = chooserActions.map { it.toCustomActionModel(pendingIntentSender) }
+}
+
+/**
+ * Returns a [CustomActionModel] that sends this [ChooserAction]'s
+ * [PendingIntent][ChooserAction.getAction].
+ */
+fun ChooserAction.toCustomActionModel(pendingIntentSender: PendingIntentSender) =
+ CustomActionModel(
+ label = label,
+ icon = icon,
+ performAction = {
+ try {
+ pendingIntentSender.send(action)
+ } catch (_: PendingIntent.CanceledException) {
+ Log.d(TAG, "Custom action, $label, has been cancelled")
+ }
+ }
+ )
+
+private const val TAG = "CustomShareActions"
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt
new file mode 100644
index 00000000..23ba31ba
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import android.app.PendingIntent
+
+/** Sends [PendingIntent]s. */
+fun interface PendingIntentSender {
+ fun send(pendingIntent: PendingIntent)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt
new file mode 100644
index 00000000..4a2a6932
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import android.content.ClipData
+import android.content.ClipDescription.compareMimeTypes
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_STREAM
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.inject.TargetIntent
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+/** Modifies target intent based on current payload selection. */
+fun interface TargetIntentModifier<Item> {
+ fun intentFromSelection(selection: Collection<Item>): Intent
+}
+
+class TargetIntentModifierImpl<Item>(
+ private val originalTargetIntent: Intent,
+ private val getUri: Item.() -> Uri,
+ private val getMimeType: Item.() -> String?,
+) : TargetIntentModifier<Item> {
+ override fun intentFromSelection(selection: Collection<Item>): Intent {
+ val uris = selection.mapTo(ArrayList()) { it.getUri() }
+ val targetMimeType =
+ selection.fold(null) { target: String?, item: Item ->
+ updateMimeType(item.getMimeType(), target)
+ }
+ return Intent(originalTargetIntent).apply {
+ if (selection.size == 1) {
+ action = ACTION_SEND
+ putExtra(EXTRA_STREAM, selection.first().getUri())
+ } else {
+ action = ACTION_SEND_MULTIPLE
+ putParcelableArrayListExtra(EXTRA_STREAM, uris)
+ }
+ type = targetMimeType
+ if (uris.isNotEmpty()) {
+ clipData =
+ ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also {
+ for (i in 1 until uris.size) {
+ it.addItem(ClipData.Item(uris[i]))
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String {
+ itemMimeType ?: return "*/*"
+ unitedMimeType ?: return itemMimeType
+ if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType
+ val slashIdx = unitedMimeType.indexOf('/')
+ if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) {
+ return buildString {
+ append(unitedMimeType.substring(0, slashIdx + 1))
+ append('*')
+ }
+ }
+ return "*/*"
+ }
+}
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object TargetIntentModifierModule {
+ @Provides
+ fun targetIntentModifier(
+ @TargetIntent targetIntent: Intent,
+ ): TargetIntentModifier<PreviewModel> =
+ TargetIntentModifierImpl(targetIntent, { uri }, { mimeType })
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt
new file mode 100644
index 00000000..953e91b3
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.map
+
+/** Stores the target intent of the share sheet, and custom actions derived from the intent. */
+class ChooserRequestInteractor
+@Inject
+constructor(
+ private val repository: ChooserRequestRepository,
+) {
+ val targetIntent: Flow<Intent>
+ get() = repository.chooserRequest.map { it.targetIntent }
+
+ val customActions: Flow<List<CustomActionModel>>
+ get() = repository.customActions.asSharedFlow()
+
+ val metadataText: Flow<CharSequence?>
+ get() = repository.chooserRequest.map { it.metadataText }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
new file mode 100644
index 00000000..59e7e15e
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
@@ -0,0 +1,420 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION
+import android.util.Log
+import com.android.intentresolver.contentpreview.UriMetadataReader
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.inject.FocusedItemIndex
+import com.android.intentresolver.util.cursor.CursorView
+import com.android.intentresolver.util.cursor.PagedCursor
+import com.android.intentresolver.util.cursor.get
+import com.android.intentresolver.util.cursor.paged
+import com.android.intentresolver.util.mapParallel
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Qualifier
+import kotlin.math.max
+import kotlin.math.min
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.mapLatest
+
+private const val TAG = "CursorPreviewsIntr"
+
+/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */
+class CursorPreviewsInteractor
+@Inject
+constructor(
+ private val interactor: SetCursorPreviewsInteractor,
+ private val selectionInteractor: SelectionInteractor,
+ @FocusedItemIndex private val focusedItemIdx: Int,
+ private val uriMetadataReader: UriMetadataReader,
+ @PageSize private val pageSize: Int,
+ @MaxLoadedPages private val maxLoadedPages: Int,
+) {
+
+ init {
+ check(pageSize > 0) { "pageSize must be greater than zero" }
+ }
+
+ /** Start reading data from [uriCursor], and listen for requests to load more. */
+ suspend fun launch(uriCursor: CursorView<CursorRow?>, initialPreviews: Iterable<PreviewModel>) {
+ // Unclaimed values from the initial selection set. Entries will be removed as the cursor is
+ // read, and any still present are inserted at the start / end of the cursor when it is
+ // reached by the user.
+ val unclaimedRecords: MutableUnclaimedMap =
+ initialPreviews
+ .asSequence()
+ .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) }
+ .toMap(ConcurrentHashMap())
+ val pagedCursor: PagedCursor<CursorRow?> = uriCursor.paged(pageSize)
+ val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0
+
+ val state =
+ loadToMaxPages(
+ startPosition = startPosition,
+ initialState = readInitialState(startPosition, pagedCursor, unclaimedRecords),
+ pagedCursor = pagedCursor,
+ unclaimedRecords = unclaimedRecords,
+ )
+ processLoadRequests(startPosition, state, pagedCursor, unclaimedRecords)
+ }
+
+ private suspend fun loadToMaxPages(
+ startPosition: Int,
+ initialState: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ var state = initialState
+ val startPageNum = state.firstLoadedPageNum
+ while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) {
+ val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
+ interactor.setPreviews(
+ previews = state.merged.values.toList(),
+ startIndex = state.startIndex,
+ hasMoreLeft = state.hasMoreLeft,
+ hasMoreRight = state.hasMoreRight,
+ leftTriggerIndex = leftTriggerIndex,
+ rightTriggerIndex = rightTriggerIndex,
+ )
+ val loadedLeft = startPageNum - state.firstLoadedPageNum
+ val loadedRight = state.lastLoadedPageNum - startPageNum
+ state =
+ when {
+ state.hasMoreLeft && loadedLeft < loadedRight ->
+ state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
+ state.hasMoreRight ->
+ state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords)
+ else -> state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
+ }
+ }
+ return state
+ }
+
+ /** Loop forever, processing any loading requests from the UI and updating local cache. */
+ private suspend fun processLoadRequests(
+ startPosition: Int,
+ initialState: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ) {
+ var state = initialState
+ while (true) {
+ val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices()
+
+ // Design note: in order to prevent load requests from the UI when it was displaying a
+ // previously-published dataset being accidentally associated with a recently-published
+ // one, we generate a new Flow of load requests for each dataset and only listen to
+ // those.
+ val loadingState: Flow<LoadDirection?> =
+ interactor.setPreviews(
+ previews = state.merged.values.toList(),
+ startIndex = state.startIndex,
+ hasMoreLeft = state.hasMoreLeft,
+ hasMoreRight = state.hasMoreRight,
+ leftTriggerIndex = leftTriggerIndex,
+ rightTriggerIndex = rightTriggerIndex,
+ )
+ state =
+ loadingState.handleOneLoadRequest(
+ startPosition,
+ state,
+ pagedCursor,
+ unclaimedRecords,
+ )
+ }
+ }
+
+ /**
+ * Suspends until a single loading request has been handled, returning the new [CursorWindow]
+ * with the loaded data incorporated.
+ */
+ private suspend fun Flow<LoadDirection?>.handleOneLoadRequest(
+ startPosition: Int,
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow =
+ mapLatest { loadDirection ->
+ loadDirection?.let {
+ when (loadDirection) {
+ LoadDirection.Left ->
+ state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords)
+ LoadDirection.Right ->
+ state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords)
+ }
+ }
+ }
+ .filterNotNull()
+ .first()
+
+ /**
+ * Returns the initial [CursorWindow], with a single page loaded that contains the
+ * [startPosition].
+ */
+ private suspend fun readInitialState(
+ startPosition: Int,
+ cursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ val startPageIdx = startPosition / pageSize
+ val hasMoreLeft = startPageIdx > 0
+ val hasMoreRight = startPageIdx < cursor.count - 1
+ val page: PreviewMap = buildMap {
+ if (!hasMoreLeft) {
+ // First read the initial page; this might claim some unclaimed Uris
+ val page =
+ cursor
+ .getPageRows(startPageIdx)
+ ?.toPage(startPosition, mutableMapOf(), unclaimedRecords)
+ // Now that unclaimed Uris are up-to-date, add them first.
+ putAllUnclaimedLeft(unclaimedRecords)
+ // Then add the loaded page
+ page?.let(::putAll)
+ } else {
+ cursor.getPageRows(startPageIdx)?.toPage(startPosition, this, unclaimedRecords)
+ }
+ // Finally, add the remainder of the unclaimed Uris.
+ if (!hasMoreRight) {
+ putAllUnclaimedRight(unclaimedRecords)
+ }
+ }
+ return CursorWindow(
+ startIndex = startPosition % pageSize,
+ firstLoadedPageNum = startPageIdx,
+ lastLoadedPageNum = startPageIdx,
+ pages = listOf(page.keys),
+ merged = page,
+ hasMoreLeft = hasMoreLeft,
+ hasMoreRight = hasMoreRight,
+ )
+ }
+
+ private suspend fun CursorWindow.loadMoreRight(
+ startPosition: Int,
+ cursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ val pageNum = lastLoadedPageNum + 1
+ val hasMoreRight = pageNum < cursor.count - 1
+ val newPage: PreviewMap = buildMap {
+ readAndPutPage(startPosition, this@loadMoreRight, cursor, pageNum, unclaimedRecords)
+ if (!hasMoreRight) {
+ putAllUnclaimedRight(unclaimedRecords)
+ }
+ }
+ return if (numLoadedPages < maxLoadedPages) {
+ expandWindowRight(newPage, hasMoreRight)
+ } else {
+ shiftWindowRight(newPage, hasMoreRight)
+ }
+ }
+
+ private suspend fun CursorWindow.loadMoreLeft(
+ startPosition: Int,
+ cursor: PagedCursor<CursorRow?>,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): CursorWindow {
+ val pageNum = firstLoadedPageNum - 1
+ val hasMoreLeft = pageNum > 0
+ val newPage: PreviewMap = buildMap {
+ if (!hasMoreLeft) {
+ // First read the page; this might claim some unclaimed Uris
+ val page =
+ readPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
+ // Now that unclaimed URIs are up-to-date, add them first
+ putAllUnclaimedLeft(unclaimedRecords)
+ // Then add the loaded page
+ putAll(page)
+ } else {
+ readAndPutPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords)
+ }
+ }
+ return if (numLoadedPages < maxLoadedPages) {
+ expandWindowLeft(newPage, hasMoreLeft)
+ } else {
+ shiftWindowLeft(newPage, hasMoreLeft)
+ }
+ }
+
+ private fun CursorWindow.triggerIndices(): Pair<Int, Int> {
+ val totalIndices = numLoadedPages * pageSize
+ val midIndex = totalIndices / 2
+ val halfPage = pageSize / 2
+ return max(midIndex - halfPage, 0) to min(midIndex + halfPage, totalIndices - 1)
+ }
+
+ private suspend fun readPage(
+ startPosition: Int,
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): PreviewMap =
+ mutableMapOf<PreviewKey, PreviewModel>()
+ .readAndPutPage(startPosition, state, pagedCursor, pageNum, unclaimedRecords)
+
+ private suspend fun <M : MutablePreviewMap> M.readAndPutPage(
+ startPosition: Int,
+ state: CursorWindow,
+ pagedCursor: PagedCursor<CursorRow?>,
+ pageNum: Int,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): M =
+ pagedCursor
+ .getPageRows(pageNum) // TODO: what do we do if the load fails?
+ ?.filter { PreviewKey.final(it.position - startPosition) !in state.merged }
+ ?.toPage(startPosition, this, unclaimedRecords) ?: this
+
+ private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage(
+ startPosition: Int,
+ destination: M,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): M =
+ // Restrict parallelism so as to not overload the metadata reader; anecdotally, too
+ // many parallel queries causes failures.
+ mapParallel(parallelism = 4) { row ->
+ createPreviewModel(startPosition, row, unclaimedRecords)
+ }
+ .associateByTo(destination) { it.key }
+
+ private fun createPreviewModel(
+ startPosition: Int,
+ row: CursorRow,
+ unclaimedRecords: MutableUnclaimedMap,
+ ): PreviewModel =
+ uriMetadataReader
+ .getMetadata(row.uri)
+ .let { metadata ->
+ val size =
+ row.previewSize
+ ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
+ PreviewModel(
+ key = PreviewKey.final(row.position - startPosition),
+ uri = row.uri,
+ previewUri = metadata.previewUri,
+ mimeType = metadata.mimeType,
+ aspectRatio = size.aspectRatioOrDefault(1f),
+ order = row.position,
+ )
+ }
+ .also { updated ->
+ if (unclaimedRecords.remove(row.uri) != null) {
+ // unclaimedRecords contains initially shared (and thus selected) items with
+ // unknown cursor position. Update selection records when any of those items is
+ // encountered in the cursor to maintain proper selection order should other
+ // items also be selected.
+ selectionInteractor.updateSelection(updated)
+ }
+ }
+
+ private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M =
+ putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx }
+
+ private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M =
+ putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx }
+}
+
+private typealias CursorWindow = LoadedWindow<PreviewKey, PreviewModel>
+
+/**
+ * Values from the initial selection set that have not yet appeared within the Cursor. These values
+ * are appended to the start/end of the cursor dataset, depending on their position relative to the
+ * initially focused value.
+ */
+private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>>
+
+/** Mutable version of [UnclaimedMap]. */
+private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>>
+
+private typealias UnkeyedMap = Map<Uri, PreviewModel>
+
+private typealias MutableUnkeyedMap = MutableMap<Uri, PreviewModel>
+
+private typealias MutablePreviewMap = MutableMap<PreviewKey, PreviewModel>
+
+private typealias PreviewMap = Map<PreviewKey, PreviewModel>
+
+private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere(
+ unclaimedRecords: UnclaimedMap,
+ predicate: (Int) -> Boolean,
+): M =
+ unclaimedRecords
+ .asSequence()
+ .filter { predicate(it.value.first) }
+ .map { (_, value) -> value.second.key to value.second }
+ .toMap(this)
+
+private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? =
+ runCatching { get(pageNum) }
+ .onFailure { Log.e(TAG, "Failed to read additional content cursor page #$pageNum", it) }
+ .getOrNull()
+ ?.asSafeSequence()
+ ?.filterNotNull()
+
+private fun <T> Sequence<T>.asSafeSequence(): Sequence<T> {
+ return if (this is SafeSequence) this else SafeSequence(this)
+}
+
+private class SafeSequence<T>(private val sequence: Sequence<T>) : Sequence<T> {
+ override fun iterator(): Iterator<T> =
+ sequence.iterator().let { if (it is SafeIterator) it else SafeIterator(it) }
+}
+
+private class SafeIterator<T>(private val iterator: Iterator<T>) : Iterator<T> by iterator {
+ override fun hasNext(): Boolean {
+ return runCatching { iterator.hasNext() }
+ .onFailure { Log.e(TAG, "Failed to read cursor", it) }
+ .getOrDefault(false)
+ }
+}
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class MaxLoadedPages
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ShareouselConstants {
+ @Provides @PageSize fun pageSize(): Int = 16
+
+ @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt
new file mode 100644
index 00000000..e973e844
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.pm.PackageManager
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel
+import com.android.intentresolver.icon.toComposeIcon
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.logging.EventLog
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+
+class CustomActionsInteractor
+@Inject
+constructor(
+ private val activityResultRepo: ActivityResultRepository,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ private val contentResolver: ContentResolver,
+ private val eventLog: EventLog,
+ private val packageManager: PackageManager,
+ private val chooserRequestInteractor: ChooserRequestInteractor,
+) {
+ /** List of [ActionModel] that can be presented in Shareousel. */
+ val customActions: Flow<List<ActionModel>>
+ get() =
+ chooserRequestInteractor.customActions
+ .map { actions ->
+ actions.map { action ->
+ ActionModel(
+ label = action.label,
+ icon = action.icon.toComposeIcon(packageManager, contentResolver),
+ performAction = { index -> performAction(action, index) },
+ )
+ }
+ }
+ .flowOn(bgDispatcher)
+ .conflate()
+
+ private fun performAction(action: CustomActionModel, index: Int) {
+ action.performAction()
+ eventLog.logCustomActionSelected(index)
+ activityResultRepo.activityResult.value = Activity.RESULT_OK
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt
new file mode 100644
index 00000000..1fd69351
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.UriMetadataReader
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.inject.ContentUris
+import com.android.intentresolver.inject.FocusedItemIndex
+import com.android.intentresolver.util.mapParallelIndexed
+import javax.inject.Inject
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+
+/** Populates the data displayed in Shareousel. */
+class FetchPreviewsInteractor
+@Inject
+constructor(
+ private val setCursorPreviews: SetCursorPreviewsInteractor,
+ private val selectionRepository: PreviewSelectionsRepository,
+ private val cursorInteractor: CursorPreviewsInteractor,
+ @FocusedItemIndex private val focusedItemIdx: Int,
+ @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>,
+ private val uriMetadataReader: UriMetadataReader,
+ @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards CursorRow?>,
+) {
+ suspend fun activate() = coroutineScope {
+ val cursor = async { cursorResolver.getCursor() }
+ val initialPreviewMap = getInitialPreviews()
+ selectionRepository.selections.value = initialPreviewMap.associateBy { it.uri }
+ setCursorPreviews.setPreviews(
+ previews = initialPreviewMap,
+ startIndex = focusedItemIdx,
+ hasMoreLeft = false,
+ hasMoreRight = false,
+ leftTriggerIndex = initialPreviewMap.indices.first(),
+ rightTriggerIndex = initialPreviewMap.indices.last(),
+ )
+ cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap)
+ }
+
+ private suspend fun getInitialPreviews(): List<PreviewModel> =
+ selectedItems
+ // Restrict parallelism so as to not overload the metadata reader; anecdotally, too
+ // many parallel queries causes failures.
+ .mapParallelIndexed(parallelism = 4) { index, uri ->
+ val metadata = uriMetadataReader.getMetadata(uri)
+ PreviewModel(
+ key =
+ if (index == focusedItemIdx) {
+ PreviewKey.final(0)
+ } else {
+ PreviewKey.temp(index)
+ },
+ uri = uri,
+ previewUri = metadata.previewUri,
+ mimeType = metadata.mimeType,
+ aspectRatio =
+ metadata.previewUri?.let {
+ uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f)
+ } ?: 1f,
+ order =
+ when {
+ index < focusedItemIdx -> Int.MIN_VALUE + index
+ index == focusedItemIdx -> 0
+ else -> Int.MAX_VALUE - selectedItems.size + index + 1
+ },
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt
new file mode 100644
index 00000000..c202eabf
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback
+import javax.inject.Inject
+import kotlinx.coroutines.flow.collectLatest
+
+/** Communicates with the sharing application to notify of changes to the target intent. */
+class ProcessTargetIntentUpdatesInteractor
+@Inject
+constructor(
+ private val selectionCallback: SelectionChangeCallback,
+ private val repository: PendingSelectionCallbackRepository,
+ private val chooserRequestInteractor: UpdateChooserRequestInteractor,
+) {
+ /** Listen for events and update state. */
+ suspend fun activate() {
+ repository.pendingTargetIntent.collectLatest { targetIntent ->
+ targetIntent ?: return@collectLatest
+ selectionCallback.onSelectionChanged(targetIntent)?.let { update ->
+ chooserRequestInteractor.applyUpdate(targetIntent, update)
+ }
+ repository.pendingTargetIntent.compareAndSet(targetIntent, null)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt
new file mode 100644
index 00000000..8f18ebe0
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.logging.EventLog
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/** An individual preview in Shareousel. */
+class SelectablePreviewInteractor(
+ private val key: PreviewModel,
+ private val selectionInteractor: SelectionInteractor,
+ private val eventLog: EventLog,
+) {
+ val uri: Uri = key.uri
+
+ /** Whether or not this preview is selected by the user. */
+ val isSelected: Flow<Boolean> = selectionInteractor.selections.map { key.uri in it }
+
+ /** Sets whether this preview is selected by the user. */
+ fun setSelected(isSelected: Boolean) {
+ eventLog.logPayloadSelectionChanged()
+ if (isSelected) {
+ selectionInteractor.select(key)
+ } else {
+ selectionInteractor.unselect(key)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt
new file mode 100644
index 00000000..d0ac8d4a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.logging.EventLog
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+
+class SelectablePreviewsInteractor
+@Inject
+constructor(
+ private val previewsRepo: CursorPreviewsRepository,
+ private val selectionInteractor: SelectionInteractor,
+ private val eventLog: EventLog,
+) {
+ /** Keys of previews available for display in Shareousel. */
+ val previews: Flow<PreviewsModel?>
+ get() = previewsRepo.previewsModel
+
+ /**
+ * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual
+ * preview associated with [key].
+ */
+ fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor, eventLog)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
new file mode 100644
index 00000000..2d02e4fd
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.Flags.unselectFinalItem
+import com.android.intentresolver.contentpreview.MimeTypeClassifier
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.updateAndGet
+
+class SelectionInteractor
+@Inject
+constructor(
+ private val selectionsRepo: PreviewSelectionsRepository,
+ private val targetIntentModifier: TargetIntentModifier<PreviewModel>,
+ private val updateTargetIntentInteractor: UpdateTargetIntentInteractor,
+ private val mimeTypeClassifier: MimeTypeClassifier,
+) {
+ /** List of selected previews. */
+ val selections: Flow<Set<Uri>> =
+ selectionsRepo.selections.map { it.keys }.distinctUntilChanged()
+
+ /** Amount of selected previews. */
+ val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size }
+
+ val aggregateContentType: Flow<ContentType> =
+ selectionsRepo.selections.map { aggregateContentType(it.values) }
+
+ fun updateSelection(model: PreviewModel) {
+ selectionsRepo.selections.update {
+ if (it.containsKey(model.uri)) it + (model.uri to model) else it
+ }
+ }
+
+ fun select(model: PreviewModel) {
+ updateChooserRequest(
+ selectionsRepo.selections.updateAndGet { it + (model.uri to model) }.values
+ )
+ }
+
+ fun unselect(model: PreviewModel) {
+ if (selectionsRepo.selections.value.size > 1 || unselectFinalItem()) {
+ selectionsRepo.selections
+ .updateAndGet { it - model.uri }
+ .values
+ .takeIf { it.isNotEmpty() }
+ ?.let { updateChooserRequest(it) }
+ }
+ }
+
+ private fun updateChooserRequest(selections: Collection<PreviewModel>) {
+ val sorted = selections.sortedBy { it.order }
+ val intent = targetIntentModifier.intentFromSelection(sorted)
+ updateTargetIntentInteractor.updateTargetIntent(intent)
+ }
+
+ private fun aggregateContentType(
+ items: Collection<PreviewModel>,
+ ): ContentType {
+ if (items.isEmpty()) {
+ return ContentType.Other
+ }
+
+ var allImages = true
+ var allVideos = true
+ for (item in items) {
+ allImages = allImages && mimeTypeClassifier.isImageType(item.mimeType)
+ allVideos = allVideos && mimeTypeClassifier.isVideoType(item.mimeType)
+
+ if (!allImages && !allVideos) {
+ break
+ }
+ }
+
+ return when {
+ allImages -> ContentType.Image
+ allVideos -> ContentType.Video
+ else -> ContentType.Other
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt
new file mode 100644
index 00000000..124e2a3d
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Updates [CursorPreviewsRepository] with new previews. */
+class SetCursorPreviewsInteractor
+@Inject
+constructor(private val previewsRepo: CursorPreviewsRepository) {
+ /** Stores new [previews], and returns a flow of load requests triggered by Shareousel. */
+ fun setPreviews(
+ previews: List<PreviewModel>,
+ startIndex: Int,
+ hasMoreLeft: Boolean,
+ hasMoreRight: Boolean,
+ leftTriggerIndex: Int,
+ rightTriggerIndex: Int
+ ): Flow<LoadDirection?> {
+ val loadingState = MutableStateFlow<LoadDirection?>(null)
+ previewsRepo.previewsModel.value =
+ PreviewsModel(
+ previewModels = previews,
+ startIdx = startIndex,
+ loadMoreLeft =
+ if (hasMoreLeft) {
+ ({ loadingState.value = LoadDirection.Left })
+ } else {
+ null
+ },
+ loadMoreRight =
+ if (hasMoreRight) {
+ ({ loadingState.value = LoadDirection.Right })
+ } else {
+ null
+ },
+ leftTriggerIndex = leftTriggerIndex,
+ rightTriggerIndex = rightTriggerIndex,
+ )
+ return loadingState.asStateFlow()
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt
new file mode 100644
index 00000000..4cf10414
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.util.Size
+
+internal fun Size?.aspectRatioOrDefault(default: Float): Float =
+ when {
+ this == null -> default
+ width >= 0 && height > 0 -> width.toFloat() / height.toFloat()
+ else -> default
+ }
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
new file mode 100644
index 00000000..fc193eca
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import com.android.intentresolver.domain.updateWith
+import javax.inject.Inject
+import kotlinx.coroutines.flow.update
+
+/** Updates the tracked chooser request. */
+class UpdateChooserRequestInteractor
+@Inject
+constructor(
+ private val repository: ChooserRequestRepository,
+ @CustomAction private val pendingIntentSender: PendingIntentSender,
+) {
+ fun applyUpdate(targetIntent: Intent, update: ShareouselUpdate) {
+ repository.chooserRequest.update { it.updateWith(targetIntent, update) }
+ update.customActions.onValue { actions ->
+ repository.customActions.value =
+ actions.map { it.toCustomActionModel(pendingIntentSender) }
+ }
+ }
+
+ fun setTargetIntent(targetIntent: Intent) {
+ repository.chooserRequest.update { it.copy(targetIntent = targetIntent) }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt
new file mode 100644
index 00000000..d99d69ab
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import javax.inject.Inject
+
+class UpdateTargetIntentInteractor
+@Inject
+constructor(
+ private val repository: PendingSelectionCallbackRepository,
+ private val chooserRequestInteractor: UpdateChooserRequestInteractor,
+) {
+ /**
+ * Updates the target intent for the chooser. This will kick off an asynchronous IPC with the
+ * sharing application, so that it can react to the new intent.
+ */
+ fun updateTargetIntent(targetIntent: Intent) {
+ repository.pendingTargetIntent.value = targetIntent
+ chooserRequestInteractor.setTargetIntent(targetIntent)
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt
new file mode 100644
index 00000000..f69365d7
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+import com.android.intentresolver.icon.ComposeIcon
+
+/** An action that the user can take, provided by the sharing application. */
+data class ActionModel(
+ /** Text shown for this action in the UI. */
+ val label: CharSequence,
+ /** An optional [ComposeIcon] that will be displayed in the UI with this action. */
+ val icon: ComposeIcon?,
+ /**
+ * Performs the action. The argument indicates the index in the UI that this action is shown.
+ */
+ val performAction: (index: Int) -> Unit,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt
new file mode 100644
index 00000000..aae29102
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+import android.net.Uri
+import android.util.Size
+
+/** Represents additional content cursor row */
+data class CursorRow(val uri: Uri, val previewSize: Size?, val position: Int)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt
new file mode 100644
index 00000000..23510f15
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+/** Specifies which side of the dataset is being loaded. */
+enum class LoadDirection {
+ Left,
+ Right,
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt
new file mode 100644
index 00000000..5e34b178
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+/** A window of data loaded from a cursor. */
+data class LoadedWindow<K, V>(
+ /** The index position of the item that should be displayed initially. */
+ val startIndex: Int,
+ /** First cursor page index loaded within this window. */
+ val firstLoadedPageNum: Int,
+ /** Last cursor page index loaded within this window. */
+ val lastLoadedPageNum: Int,
+ /** Keys of cursor data within this window, grouped by loaded page. */
+ val pages: List<Set<K>>,
+ /** Merged set of all cursor data within this window. */
+ val merged: Map<K, V>,
+ /** Is there more data to the left of this window? */
+ val hasMoreLeft: Boolean,
+ /** Is there more data to the right of this window? */
+ val hasMoreRight: Boolean,
+)
+
+/** Number of loaded pages stored within this [LoadedWindow]. */
+val LoadedWindow<*, *>.numLoadedPages: Int
+ get() = (lastLoadedPageNum - firstLoadedPageNum) + 1
+
+/** Inserts [newPage] to the right, and removes the leftmost page from the window. */
+fun <K, V> LoadedWindow<K, V>.shiftWindowRight(
+ newPage: Map<K, V>,
+ hasMore: Boolean,
+): LoadedWindow<K, V> =
+ LoadedWindow(
+ startIndex = startIndex - newPage.size,
+ firstLoadedPageNum = firstLoadedPageNum + 1,
+ lastLoadedPageNum = lastLoadedPageNum + 1,
+ pages = pages.drop(1) + listOf(newPage.keys),
+ merged =
+ buildMap {
+ putAll(merged)
+ pages.first().forEach(::remove)
+ putAll(newPage)
+ },
+ hasMoreLeft = true,
+ hasMoreRight = hasMore,
+ )
+
+/** Inserts [newPage] to the right, increasing the size of the window to accommodate it. */
+fun <K, V> LoadedWindow<K, V>.expandWindowRight(
+ newPage: Map<K, V>,
+ hasMore: Boolean,
+): LoadedWindow<K, V> =
+ LoadedWindow(
+ startIndex = startIndex,
+ firstLoadedPageNum = firstLoadedPageNum,
+ lastLoadedPageNum = lastLoadedPageNum + 1,
+ pages = pages + listOf(newPage.keys),
+ merged = merged + newPage,
+ hasMoreLeft = hasMoreLeft,
+ hasMoreRight = hasMore,
+ )
+
+/** Inserts [newPage] to the left, and removes the rightmost page from the window. */
+fun <K, V> LoadedWindow<K, V>.shiftWindowLeft(
+ newPage: Map<K, V>,
+ hasMore: Boolean,
+): LoadedWindow<K, V> =
+ LoadedWindow(
+ startIndex = startIndex + newPage.size,
+ firstLoadedPageNum = firstLoadedPageNum - 1,
+ lastLoadedPageNum = lastLoadedPageNum - 1,
+ pages = listOf(newPage.keys) + pages.dropLast(1),
+ merged =
+ buildMap {
+ putAll(newPage)
+ putAll(merged - pages.last())
+ },
+ hasMoreLeft = hasMore,
+ hasMoreRight = true,
+ )
+
+/** Inserts [newPage] to the left, increasing the size olf the window to accommodate it. */
+fun <K, V> LoadedWindow<K, V>.expandWindowLeft(
+ newPage: Map<K, V>,
+ hasMore: Boolean,
+): LoadedWindow<K, V> =
+ LoadedWindow(
+ startIndex = startIndex + newPage.size,
+ firstLoadedPageNum = firstLoadedPageNum - 1,
+ lastLoadedPageNum = lastLoadedPageNum,
+ pages = listOf(newPage.keys) + pages,
+ merged = newPage + merged,
+ hasMoreLeft = hasMore,
+ hasMoreRight = hasMoreRight,
+ )
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
new file mode 100644
index 00000000..77f196e6
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.IntentSender
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+
+/** Sharing session updates provided by the sharing app from the payload change callback */
+data class ShareouselUpdate(
+ // for all properties, null value means no change
+ val customActions: ValueUpdate<List<ChooserAction>> = ValueUpdate.Absent,
+ val modifyShareAction: ValueUpdate<ChooserAction?> = ValueUpdate.Absent,
+ val alternateIntents: ValueUpdate<List<Intent>> = ValueUpdate.Absent,
+ val callerTargets: ValueUpdate<List<ChooserTarget>> = ValueUpdate.Absent,
+ val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent,
+ val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent,
+ val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent,
+ val excludeComponents: ValueUpdate<List<ComponentName>> = ValueUpdate.Absent,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt
new file mode 100644
index 00000000..bad4eebe
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.model
+
+/** Represents an either updated value or the absence of it */
+sealed interface ValueUpdate<out T> {
+ data class Value<T>(val value: T) : ValueUpdate<T>
+ data object Absent : ValueUpdate<Nothing>
+}
+
+/** Return encapsulated value if this instance represent Value or `default` if Absent */
+fun <T> ValueUpdate<T>.getOrDefault(default: T): T =
+ when (this) {
+ is ValueUpdate.Value -> value
+ is ValueUpdate.Absent -> default
+ }
+
+/** Executes the `block` with encapsulated value if this instance represents Value */
+inline fun <T> ValueUpdate<T>.onValue(block: (T) -> Unit) {
+ if (this is ValueUpdate.Value) {
+ block(value)
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
new file mode 100644
index 00000000..184cc027
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.update
+
+import android.content.ComponentName
+import android.content.ContentInterface
+import android.content.Intent
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_METADATA_TEXT
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.inject.AdditionalContent
+import com.android.intentresolver.inject.ChooserIntent
+import com.android.intentresolver.ui.viewmodel.readAlternateIntents
+import com.android.intentresolver.ui.viewmodel.readChooserActions
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.log
+import com.android.intentresolver.validation.types.array
+import com.android.intentresolver.validation.types.value
+import com.android.intentresolver.validation.validateFrom
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import javax.inject.Inject
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+private const val TAG = "SelectionChangeCallback"
+
+/**
+ * Encapsulates payload change callback invocation to the sharing app; handles callback arguments
+ * and result format mapping.
+ */
+fun interface SelectionChangeCallback {
+ suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate?
+}
+
+class SelectionChangeCallbackImpl
+@Inject
+constructor(
+ @AdditionalContent private val uri: Uri,
+ @ChooserIntent private val chooserIntent: Intent,
+ private val contentResolver: ContentInterface,
+) : SelectionChangeCallback {
+ private val mutex = Mutex()
+
+ override suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? =
+ mutex
+ .withLock {
+ contentResolver.call(
+ requireNotNull(uri.authority) { "URI authority can not be null" },
+ ON_SELECTION_CHANGED,
+ uri.toString(),
+ Bundle().apply {
+ putParcelable(
+ EXTRA_INTENT,
+ Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) }
+ )
+ }
+ )
+ }
+ ?.let { bundle ->
+ return when (val result = readCallbackResponse(bundle)) {
+ is Valid -> {
+ result.warnings.forEach { it.log(TAG) }
+ result.value
+ }
+ is Invalid -> {
+ result.errors.forEach { it.log(TAG) }
+ null
+ }
+ }
+ }
+}
+
+private fun readCallbackResponse(
+ bundle: Bundle,
+): ValidationResult<ShareouselUpdate> {
+ return validateFrom(bundle::get) {
+ // An error is treated as an empty collection or null as the presence of a value indicates
+ // an intention to change the old value implying that the old value is obsolete (and should
+ // not be used).
+ val customActions =
+ bundle.readValueUpdate(EXTRA_CHOOSER_CUSTOM_ACTIONS) {
+ readChooserActions() ?: emptyList()
+ }
+ val modifyShareAction =
+ bundle.readValueUpdate(EXTRA_CHOOSER_MODIFY_SHARE_ACTION) { key ->
+ optional(value<ChooserAction>(key))
+ }
+ val alternateIntents =
+ bundle.readValueUpdate(EXTRA_ALTERNATE_INTENTS) {
+ readAlternateIntents() ?: emptyList()
+ }
+ val callerTargets =
+ bundle.readValueUpdate(EXTRA_CHOOSER_TARGETS) { key ->
+ optional(array<ChooserTarget>(key)) ?: emptyList()
+ }
+ val refinementIntentSender =
+ bundle.readValueUpdate(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER) { key ->
+ optional(value<IntentSender>(key))
+ }
+ val resultIntentSender =
+ bundle.readValueUpdate(EXTRA_CHOOSER_RESULT_INTENT_SENDER) { key ->
+ optional(value<IntentSender>(key))
+ }
+ val metadataText =
+ bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key ->
+ optional(value<CharSequence>(key))
+ }
+ val excludedComponents: ValueUpdate<List<ComponentName>> =
+ if (shareouselUpdateExcludeComponentsExtra()) {
+ bundle.readValueUpdate(EXTRA_EXCLUDE_COMPONENTS) { key ->
+ optional(array<ComponentName>(key)) ?: emptyList()
+ }
+ } else {
+ ValueUpdate.Absent
+ }
+
+ ShareouselUpdate(
+ customActions,
+ modifyShareAction,
+ alternateIntents,
+ callerTargets,
+ refinementIntentSender,
+ resultIntentSender,
+ metadataText,
+ excludedComponents,
+ )
+ }
+}
+
+private inline fun <reified T> Bundle.readValueUpdate(
+ key: String,
+ block: (String) -> T
+): ValueUpdate<T> =
+ if (containsKey(key)) {
+ ValueUpdate.Value(block(key))
+ } else {
+ ValueUpdate.Absent
+ }
+
+@Module
+@InstallIn(ViewModelComponent::class)
+interface SelectionChangeCallbackModule {
+ @Binds fun bind(impl: SelectionChangeCallbackImpl): SelectionChangeCallback
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt
new file mode 100644
index 00000000..3ef6d98f
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.shared
+
+/** Type of the content being previewed. */
+enum class ContentType {
+ Image,
+ Video,
+ Other
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt
new file mode 100644
index 00000000..6b42133e
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.shared.model
+
+/** Unique identifier for preview items. */
+sealed interface PreviewKey {
+
+ private data class Temp(override val key: Int, override val isFinal: Boolean = false) :
+ PreviewKey
+
+ private data class Final(override val key: Int, override val isFinal: Boolean = true) :
+ PreviewKey
+
+ /** The identifier, must be unique among like keys types */
+ val key: Int
+ /** Whether this key is final or temporary. */
+ val isFinal: Boolean
+
+ companion object {
+ /**
+ * Creates a temporary key.
+ *
+ * This is used for the initial preview items until final keys can be generated, at which
+ * point it is replaced with a final key.
+ */
+ fun temp(key: Int): PreviewKey = Temp(key)
+
+ /**
+ * Creates a final key.
+ *
+ * This is used for all preview items other than the initial preview items.
+ */
+ fun final(key: Int): PreviewKey = Final(key)
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt
new file mode 100644
index 00000000..d4df8a3a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.shared.model
+
+import android.net.Uri
+
+/** An individual preview presented in Shareousel. */
+data class PreviewModel(
+ /** Unique identifier for this model. */
+ val key: PreviewKey,
+ /** Uri for this item; if this preview is selected, this will be shared with the target app. */
+ val uri: Uri,
+ /** Uri for the preview image. */
+ val previewUri: Uri? = uri,
+ /** Mimetype for the data [uri] points to. */
+ val mimeType: String?,
+ val aspectRatio: Float = 1f,
+ /**
+ * Relative item position in the list that is used to determine items order in the target
+ * intent.
+ */
+ val order: Int,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt
new file mode 100644
index 00000000..ae8bd1eb
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.shared.model
+
+/** A dataset of previews for Shareousel. */
+data class PreviewsModel(
+ /** All available [PreviewModel]s. */
+ val previewModels: List<PreviewModel>,
+ /** Index into [previewModels] that should be initially displayed to the user. */
+ val startIdx: Int,
+ /**
+ * Signals that more data should be loaded to the left of this dataset. A `null` value indicates
+ * that there is no more data to load in that direction.
+ */
+ val loadMoreLeft: (() -> Unit)?,
+ /**
+ * Signals that more data should be loaded to the right of this dataset. A `null` value
+ * indicates that there is no more data to load in that direction.
+ */
+ val loadMoreRight: (() -> Unit)?,
+ /**
+ * Index into [previewModels] where any attempted access less than or equal to it should trigger
+ * a window shift left.
+ */
+ val leftTriggerIndex: Int,
+ /**
+ * Index into [previewModels] where any attempted access greater than or equal to it should
+ * trigger a window shift right.
+ */
+ val rightTriggerIndex: Int,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt
new file mode 100644
index 00000000..8cf237da
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.composable
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Resources
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import com.android.intentresolver.icon.AdaptiveIcon
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.icon.ComposeIcon
+import com.android.intentresolver.icon.ResourceIcon
+
+@Composable
+fun Image(icon: ComposeIcon, modifier: Modifier = Modifier, colorFilter: ColorFilter? = null) {
+ when (icon) {
+ is AdaptiveIcon -> Image(icon.wrapped, modifier, colorFilter = colorFilter)
+ is BitmapIcon ->
+ Image(
+ icon.bitmap.asImageBitmap(),
+ contentDescription = null,
+ modifier = modifier,
+ colorFilter = colorFilter
+ )
+ is ResourceIcon -> {
+ val localContext = LocalContext.current
+ val wrappedContext: Context =
+ object : ContextWrapper(localContext) {
+ override fun getResources(): Resources = icon.res
+ }
+ CompositionLocalProvider(LocalContext provides wrappedContext) {
+ Image(
+ painterResource(icon.resId),
+ contentDescription = null,
+ modifier = modifier,
+ colorFilter = colorFilter
+ )
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt
new file mode 100644
index 00000000..197d6858
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+
+@Composable
+fun ShareouselCard(
+ image: @Composable () -> Unit,
+ contentType: ContentType,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier) {
+ image()
+ val topButtonPadding = 12.dp
+ Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) {
+ SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart))
+ when (contentType) {
+ ContentType.Video ->
+ TypeIcon(
+ R.drawable.ic_play_circle_filled_24px,
+ modifier = Modifier.align(Alignment.TopEnd)
+ )
+ ContentType.Other ->
+ TypeIcon(
+ R.drawable.chooser_file_generic,
+ modifier = Modifier.align(Alignment.TopEnd)
+ )
+ ContentType.Image -> Unit // No additional icon needed.
+ }
+ }
+ }
+}
+
+@Composable
+private fun TypeIcon(drawableResource: Int, modifier: Modifier = Modifier) {
+ Icon(
+ painterResource(id = drawableResource),
+ contentDescription = null, // Type attribute described at a higher level.
+ tint = Color.White,
+ modifier = Modifier.size(20.dp).then(modifier)
+ )
+}
+
+@Composable
+private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) {
+ if (selected) {
+ val bgColor = MaterialTheme.colorScheme.primary
+ Icon(
+ painter = painterResource(id = R.drawable.checkbox),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ contentDescription = null,
+ modifier =
+ Modifier.shadow(
+ elevation = 50.dp,
+ spotColor = Color(0x40000000),
+ ambientColor = Color(0x40000000)
+ )
+ .size(20.dp)
+ .drawBehind {
+ drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f)
+ }
+ .then(modifier)
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier.shadow(
+ elevation = 50.dp,
+ spotColor = Color(0x40000000),
+ ambientColor = Color(0x40000000),
+ )
+ .border(
+ width = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ shape = CircleShape
+ )
+ .clip(CircleShape)
+ .size(20.dp)
+ .background(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f))
+ .then(modifier)
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
new file mode 100644
index 00000000..9bc8d3e2
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
@@ -0,0 +1,445 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.composable
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.systemGestureExclusion
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections
+import com.android.intentresolver.Flags.shareouselSelectionShrink
+import com.android.intentresolver.Flags.unselectFinalItem
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+@Composable
+fun Shareousel(viewModel: ShareouselViewModel) {
+ val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value
+ if (keySet != null) {
+ Shareousel(viewModel, keySet)
+ } else {
+ Spacer(
+ Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp)
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ )
+ }
+}
+
+@Composable
+private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) {
+ Column(
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(vertical = 16.dp)
+ ) {
+ PreviewCarousel(keySet, viewModel)
+ ActionCarousel(viewModel)
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewModel) {
+ var measurements by remember { mutableStateOf(PreviewCarouselMeasurements.UNMEASURED) }
+ Box(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ measurements =
+ if (placeable.height <= 0) {
+ PreviewCarouselMeasurements.UNMEASURED
+ } else {
+ PreviewCarouselMeasurements(placeable, measureScope = this)
+ }
+ layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+ ) {
+ // Do not compose the list until we have measured values
+ if (measurements == PreviewCarouselMeasurements.UNMEASURED) return@Box
+
+ val prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }
+ val carouselState = remember {
+ LazyListState(
+ prefetchStrategy = prefetchStrategy,
+ firstVisibleItemIndex = previews.startIdx,
+ firstVisibleItemScrollOffset =
+ measurements.scrollOffsetToCenter(
+ previewModel = previews.previewModels[previews.startIdx]
+ ),
+ )
+ }
+
+ LazyRow(
+ state = carouselState,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding =
+ PaddingValues(
+ start = measurements.horizontalPaddingDp,
+ end = measurements.horizontalPaddingDp,
+ ),
+ modifier = Modifier.fillMaxSize().systemGestureExclusion(),
+ ) {
+ itemsIndexed(
+ items = previews.previewModels,
+ key = { _, model -> model.key.key to model.key.isFinal },
+ ) { index, model ->
+ val visibleItem by remember {
+ derivedStateOf {
+ carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
+ }
+ }
+
+ // Index if this is the element in the center of the viewing area, otherwise null
+ val previewIndex by remember {
+ derivedStateOf {
+ visibleItem?.let {
+ val halfPreviewWidth = it.size / 2
+ val previewCenter = it.offset + halfPreviewWidth
+ val previewDistanceToViewportCenter =
+ abs(previewCenter - measurements.viewportCenterPx)
+ if (previewDistanceToViewportCenter <= halfPreviewWidth) {
+ index
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ val previewModel =
+ viewModel.preview(
+ /* key = */ model,
+ /* previewHeight = */ measurements.viewportHeightPx,
+ /* index = */ previewIndex,
+ /* scope = */ rememberCoroutineScope(),
+ )
+
+ if (shareouselScrollOffscreenSelections()) {
+ LaunchedEffect(index, model.uri) {
+ var current: Boolean? = null
+ previewModel.isSelected.collect { selected ->
+ when {
+ // First update will always be the current state, so we just want to
+ // record the state and do nothing else.
+ current == null -> current = selected
+
+ // We only want to act when the state changes
+ current != selected -> {
+ current = selected
+ with(carouselState.layoutInfo) {
+ visibleItemsInfo
+ .firstOrNull { it.index == index }
+ ?.let { item ->
+ when {
+ // Item is partially past start of viewport
+ item.offset < viewportStartOffset ->
+ measurements.scrollOffsetToStartEdge()
+ // Item is partially past end of viewport
+ (item.offset + item.size) > viewportEndOffset ->
+ measurements.scrollOffsetToEndEdge(model)
+ // Item is fully within viewport
+ else -> null
+ }?.let { scrollOffset ->
+ carouselState.animateScrollToItem(
+ index = index,
+ scrollOffset = scrollOffset,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ShareouselCard(
+ viewModel = previewModel,
+ aspectRatio = measurements.coerceAspectRatio(previewModel.aspectRatio),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, aspectRatio: Float) {
+ val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle()
+ val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
+ val borderColor = MaterialTheme.colorScheme.primary
+ val scope = rememberCoroutineScope()
+ val contentDescription =
+ when (viewModel.contentType) {
+ ContentType.Image -> stringResource(R.string.selectable_image)
+ ContentType.Video -> stringResource(R.string.selectable_video)
+ else -> stringResource(R.string.selectable_item)
+ }
+ Box(
+ modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio),
+ contentAlignment = Alignment.Center,
+ ) {
+ Crossfade(
+ targetState = bitmapLoadState,
+ modifier =
+ Modifier.semantics { this.contentDescription = contentDescription }
+ .toggleable(
+ value = selected,
+ onValueChange = { scope.launch { viewModel.setSelected(it) } },
+ )
+ .conditional(shareouselSelectionShrink()) {
+ val selectionScale by animateFloatAsState(if (selected) 0.95f else 1f)
+ scale(selectionScale)
+ }
+ .clip(RoundedCornerShape(size = 12.dp)),
+ ) { state ->
+ if (state is ValueUpdate.Value) {
+ state.getOrDefault(null).let { bitmap ->
+ ShareouselCard(
+ image = {
+ bitmap?.let {
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.aspectRatio(aspectRatio),
+ )
+ } ?: PlaceholderBox(aspectRatio)
+ },
+ contentType = viewModel.contentType,
+ selected = selected,
+ modifier =
+ Modifier.conditional(selected) {
+ border(
+ width = 4.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(size = 12.dp),
+ )
+ },
+ )
+ }
+ } else {
+ PlaceholderBox(aspectRatio)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaceholderBox(aspectRatio: Float) {
+ Box(
+ modifier =
+ Modifier.fillMaxHeight()
+ .aspectRatio(aspectRatio)
+ .background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
+ )
+}
+
+@Composable
+private fun ActionCarousel(viewModel: ShareouselViewModel) {
+ val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
+ if (actions.isNotEmpty()) {
+ Spacer(Modifier.height(16.dp))
+ val visibilityFlow =
+ if (unselectFinalItem()) {
+ viewModel.hasSelectedItems
+ } else {
+ MutableStateFlow(true)
+ }
+ val visibility by visibilityFlow.collectAsStateWithLifecycle(true)
+ val height = 32.dp
+ if (visibility) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.height(height),
+ ) {
+ itemsIndexed(actions) { idx, actionViewModel ->
+ if (idx == 0) {
+ Spacer(
+ Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
+ )
+ }
+ ShareouselAction(
+ label = actionViewModel.label,
+ onClick = { actionViewModel.onClicked() },
+ ) {
+ actionViewModel.icon?.let {
+ Image(
+ icon = it,
+ modifier = Modifier.size(16.dp),
+ colorFilter = ColorFilter.tint(LocalContentColor.current),
+ )
+ }
+ }
+ if (idx == actions.size - 1) {
+ Spacer(
+ Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
+ )
+ }
+ }
+ }
+ } else {
+ Spacer(modifier = Modifier.height(height))
+ }
+ }
+}
+
+@Composable
+private fun ShareouselAction(
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ leadingIcon: (@Composable () -> Unit)? = null,
+) {
+ AssistChip(
+ onClick = onClick,
+ label = { Text(label) },
+ leadingIcon = leadingIcon,
+ border = null,
+ shape = RoundedCornerShape(1000.dp), // pill shape.
+ colors =
+ AssistChipDefaults.assistChipColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ labelColor = MaterialTheme.colorScheme.onSurface,
+ leadingIconContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ modifier = modifier,
+ )
+}
+
+@Composable
+private inline fun Modifier.conditional(
+ condition: Boolean,
+ crossinline whenTrue: @Composable Modifier.() -> Modifier,
+): Modifier = if (condition) this.whenTrue() else this
+
+private data class PreviewCarouselMeasurements(
+ val viewportHeightPx: Int,
+ val viewportWidthPx: Int,
+ val viewportCenterPx: Int = viewportWidthPx / 2,
+ val maxAspectRatio: Float,
+ val horizontalPaddingPx: Int,
+ val horizontalPaddingDp: Dp,
+) {
+ constructor(
+ placeable: Placeable,
+ measureScope: MeasureScope,
+ horizontalPadding: Float = (placeable.width - (MIN_ASPECT_RATIO * placeable.height)) / 2,
+ ) : this(
+ viewportHeightPx = placeable.height,
+ viewportWidthPx = placeable.width,
+ maxAspectRatio =
+ with(measureScope) {
+ min(
+ (placeable.width - 32.dp.roundToPx()).toFloat() / placeable.height,
+ MAX_ASPECT_RATIO,
+ )
+ },
+ horizontalPaddingPx = horizontalPadding.roundToInt(),
+ horizontalPaddingDp = with(measureScope) { horizontalPadding.toDp() },
+ )
+
+ fun coerceAspectRatio(ratio: Float): Float = ratio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
+
+ fun scrollOffsetToCenter(previewModel: PreviewModel): Int =
+ horizontalPaddingPx + (aspectRatioToWidthPx(previewModel.aspectRatio) / 2) -
+ viewportCenterPx
+
+ fun scrollOffsetToStartEdge(): Int = horizontalPaddingPx
+
+ fun scrollOffsetToEndEdge(previewModel: PreviewModel): Int =
+ horizontalPaddingPx + aspectRatioToWidthPx(previewModel.aspectRatio) - viewportWidthPx
+
+ private fun aspectRatioToWidthPx(ratio: Float): Int =
+ (coerceAspectRatio(ratio) * viewportHeightPx).roundToInt()
+
+ companion object {
+ private const val MIN_ASPECT_RATIO = 0.4f
+ private const val MAX_ASPECT_RATIO = 2.5f
+
+ val UNMEASURED =
+ PreviewCarouselMeasurements(
+ viewportHeightPx = 0,
+ viewportWidthPx = 0,
+ maxAspectRatio = 0f,
+ horizontalPaddingPx = 0,
+ horizontalPaddingDp = 0.dp,
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt
new file mode 100644
index 00000000..e47700f1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselLazyListPrefetchStrategy.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.composable
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.LazyListItemInfo
+import androidx.compose.foundation.lazy.LazyListLayoutInfo
+import androidx.compose.foundation.lazy.LazyListPrefetchScope
+import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
+import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
+import androidx.compose.foundation.lazy.layout.NestedPrefetchScope
+
+/** Prefetch strategy to fetch items ahead and behind the current scroll position. */
+@OptIn(ExperimentalFoundationApi::class)
+class ShareouselLazyListPrefetchStrategy(
+ private val lookAhead: Int = 4,
+ private val lookBackward: Int = 1
+) : LazyListPrefetchStrategy {
+ // Map of index -> prefetch handle
+ private val prefetchHandles: MutableMap<Int, LazyLayoutPrefetchState.PrefetchHandle> =
+ mutableMapOf()
+
+ private var prefetchRange = IntRange.EMPTY
+
+ private enum class ScrollDirection {
+ UNKNOWN, // The user hasn't scrolled in either direction yet.
+ FORWARD,
+ BACKWARD,
+ }
+
+ private var scrollDirection: ScrollDirection = ScrollDirection.UNKNOWN
+
+ override fun LazyListPrefetchScope.onScroll(delta: Float, layoutInfo: LazyListLayoutInfo) {
+ if (layoutInfo.visibleItemsInfo.isNotEmpty()) {
+ scrollDirection = if (delta < 0) ScrollDirection.FORWARD else ScrollDirection.BACKWARD
+ updatePrefetchSet(layoutInfo.visibleItemsInfo)
+ }
+
+ if (scrollDirection == ScrollDirection.FORWARD) {
+ val lastItem = layoutInfo.visibleItemsInfo.last()
+ val spacing = layoutInfo.mainAxisItemSpacing
+ val distanceToPrefetchItem =
+ lastItem.offset + lastItem.size + spacing - layoutInfo.viewportEndOffset
+ // if in the next frame we will get the same delta will we reach the item?
+ if (distanceToPrefetchItem < -delta) {
+ prefetchHandles.get(lastItem.index + 1)?.markAsUrgent()
+ }
+ } else {
+ val firstItem = layoutInfo.visibleItemsInfo.first()
+ val distanceToPrefetchItem = layoutInfo.viewportStartOffset - firstItem.offset
+ // if in the next frame we will get the same delta will we reach the item?
+ if (distanceToPrefetchItem < delta) {
+ prefetchHandles.get(firstItem.index - 1)?.markAsUrgent()
+ }
+ }
+ }
+
+ override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) {
+ if (layoutInfo.visibleItemsInfo.isNotEmpty()) {
+ updatePrefetchSet(layoutInfo.visibleItemsInfo)
+ }
+ }
+
+ override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) {}
+
+ private fun getVisibleRange(visibleItems: List<LazyListItemInfo>) =
+ if (visibleItems.isEmpty()) IntRange.EMPTY
+ else IntRange(visibleItems.first().index, visibleItems.last().index)
+
+ /** Update prefetchRange based upon the visible item range and scroll direction. */
+ private fun updatePrefetchRange(visibleRange: IntRange) {
+ prefetchRange =
+ when (scrollDirection) {
+ // Prefetch in both directions
+ ScrollDirection.UNKNOWN ->
+ visibleRange.first - lookAhead / 2..visibleRange.last + lookAhead / 2
+ ScrollDirection.FORWARD ->
+ visibleRange.first - lookBackward..visibleRange.last + lookAhead
+ ScrollDirection.BACKWARD ->
+ visibleRange.first - lookAhead..visibleRange.last + lookBackward
+ }
+ }
+
+ private fun LazyListPrefetchScope.updatePrefetchSet(visibleItems: List<LazyListItemInfo>) {
+ val visibleRange = getVisibleRange(visibleItems)
+ updatePrefetchRange(visibleRange)
+ updatePrefetchOperations(visibleRange)
+ }
+
+ private fun LazyListPrefetchScope.updatePrefetchOperations(visibleItemsRange: IntRange) {
+ // Remove any fetches outside of the prefetch range or inside the visible range
+ prefetchHandles
+ .filterKeys { it !in prefetchRange || it in visibleItemsRange }
+ .forEach {
+ it.value.cancel()
+ prefetchHandles.remove(it.key)
+ }
+
+ // Ensure all non-visible items in the range are being prefetched
+ prefetchRange.forEach {
+ if (it !in visibleItemsRange && !prefetchHandles.containsKey(it)) {
+ prefetchHandles[it] = schedulePrefetch(it)
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt
new file mode 100644
index 00000000..728c573b
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import com.android.intentresolver.icon.ComposeIcon
+
+/** An action chip presented to the user underneath Shareousel. */
+data class ActionChipViewModel(
+ /** Text label. */
+ val label: String,
+ /** Optional icon, displayed next to the text label. */
+ val icon: ComposeIcon?,
+ /** Handles user clicks on this action in the UI. */
+ val onClicked: () -> Unit,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt
new file mode 100644
index 00000000..de435290
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import android.graphics.Bitmap
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+
+/** An individual preview within Shareousel. */
+data class ShareouselPreviewViewModel(
+ /** Image to be shared. */
+ val bitmapLoadState: StateFlow<ValueUpdate<Bitmap?>>,
+ /** Type of data to be shared. */
+ val contentType: ContentType,
+ /** Whether this preview has been selected by the user. */
+ val isSelected: Flow<Boolean>,
+ /** Sets whether this preview has been selected by the user. */
+ val setSelected: suspend (Boolean) -> Unit,
+ val aspectRatio: Float,
+)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
new file mode 100644
index 00000000..6baf5935
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import android.util.Size
+import com.android.intentresolver.Flags.unselectFinalItem
+import com.android.intentresolver.contentpreview.HeadlineGenerator
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.MimeTypeClassifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.inject.ViewModelOwned
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.zip
+
+/** A dynamic carousel of selectable previews within share sheet. */
+data class ShareouselViewModel(
+ /** Text displayed at the top of the share sheet when Shareousel is present. */
+ val headline: Flow<String>,
+ /** App-provided text shown beneath the headline. */
+ val metadataText: Flow<CharSequence?>,
+ /**
+ * Previews which are available for presentation within Shareousel. Use [preview] to create a
+ * [ShareouselPreviewViewModel] for a given [PreviewModel].
+ */
+ val previews: Flow<PreviewsModel?>,
+ /** List of action chips presented underneath Shareousel. */
+ val actions: Flow<List<ActionChipViewModel>>,
+ /** Indicates whether there are any selected items */
+ val hasSelectedItems: Flow<Boolean>,
+ /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */
+ val preview:
+ (
+ key: PreviewModel, previewHeight: Int, index: Int?, scope: CoroutineScope,
+ ) -> ShareouselPreviewViewModel,
+)
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object ShareouselViewModelModule {
+
+ @Provides
+ fun create(
+ interactor: SelectablePreviewsInteractor,
+ imageLoader: ImageLoader,
+ actionsInteractor: CustomActionsInteractor,
+ headlineGenerator: HeadlineGenerator,
+ selectionInteractor: SelectionInteractor,
+ chooserRequestInteractor: ChooserRequestInteractor,
+ mimeTypeClassifier: MimeTypeClassifier,
+ // TODO: remove if possible
+ @ViewModelOwned scope: CoroutineScope,
+ ): ShareouselViewModel {
+ val keySet = interactor.previews.stateIn(scope, SharingStarted.Eagerly, initialValue = null)
+ return ShareouselViewModel(
+ headline =
+ selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) {
+ contentType,
+ numItems ->
+ if (unselectFinalItem() && numItems == 0) {
+ headlineGenerator.getNotItemsSelectedHeadline()
+ } else {
+ when (contentType) {
+ ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
+ ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
+ ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
+ }
+ }
+ },
+ metadataText = chooserRequestInteractor.metadataText,
+ previews = keySet,
+ actions =
+ actionsInteractor.customActions.map { actions ->
+ actions.mapIndexedNotNull { i, model ->
+ val icon = model.icon
+ val label = model.label
+ if (icon == null && label.isBlank()) {
+ null
+ } else {
+ ActionChipViewModel(
+ label = label.toString(),
+ icon = model.icon,
+ onClicked = { model.performAction(i) },
+ )
+ }
+ }
+ },
+ hasSelectedItems =
+ selectionInteractor.selections.map { it.isNotEmpty() }.distinctUntilChanged(),
+ preview = { key, previewHeight, index, previewScope ->
+ keySet.value?.maybeLoad(index)
+ val previewInteractor = interactor.preview(key)
+ val contentType =
+ when {
+ mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image
+ mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video
+ else -> ContentType.Other
+ }
+ val initialBitmapValue =
+ key.previewUri?.let {
+ imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) }
+ } ?: ValueUpdate.Absent
+ ShareouselPreviewViewModel(
+ bitmapLoadState =
+ flow {
+ val previewWidth =
+ if (key.aspectRatio > 0) {
+ previewHeight.toFloat() / key.aspectRatio
+ } else {
+ previewHeight
+ }
+ .toInt()
+ emit(
+ key.previewUri?.let {
+ ValueUpdate.Value(
+ imageLoader(it, Size(previewWidth, previewHeight))
+ )
+ } ?: ValueUpdate.Absent
+ )
+ }
+ .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue),
+ contentType = contentType,
+ isSelected = previewInteractor.isSelected,
+ setSelected = previewInteractor::setSelected,
+ aspectRatio = key.aspectRatio,
+ )
+ },
+ )
+ }
+}
+
+private fun PreviewsModel.maybeLoad(index: Int?) {
+ when {
+ index == null -> {}
+ index <= leftTriggerIndex -> loadMoreLeft?.invoke()
+ index >= rightTriggerIndex -> loadMoreRight?.invoke()
+ }
+}
diff --git a/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt
new file mode 100644
index 00000000..cf31ea10
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.UserHandle
+import android.util.Log
+import com.android.intentresolver.inject.Broadcast
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.onFailure
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+private const val TAG = "BroadcastSubscriber"
+
+class BroadcastSubscriber
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ @Broadcast private val handler: Handler
+) {
+ /**
+ * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new
+ * value whenever broadcast matching _filter_ is received. The result value will be computed
+ * using [transform] and emitted if non-null.
+ */
+ fun <T> createFlow(
+ filter: IntentFilter,
+ user: UserHandle,
+ transform: (Intent) -> T?,
+ ): Flow<T> = callbackFlow {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ transform(intent)?.also { result ->
+ trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) }
+ }
+ ?: Log.w(TAG, "Ignored broadcast $intent")
+ }
+ }
+
+ @Suppress("MissingPermission")
+ context.registerReceiverAsUser(
+ receiver,
+ user,
+ IntentFilter(filter),
+ null,
+ handler,
+ Context.RECEIVER_NOT_EXPORTED
+ )
+ awaitClose { context.unregisterReceiver(receiver) }
+ }
+}
diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
new file mode 100644
index 00000000..ad338103
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.model
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_REFERRER
+import android.content.IntentFilter
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import androidx.annotation.StringRes
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.IChooserInteractiveSessionCallback
+import com.android.intentresolver.ext.hasAction
+import com.android.systemui.shared.Flags.screenshotContextUrl
+
+const val ANDROID_APP_SCHEME = "android-app"
+
+/** All of the things that are consumed from an incoming share Intent (+Extras). */
+data class ChooserRequest(
+ /** Required. Represents the content being sent. */
+ val targetIntent: Intent,
+
+ /** The action from [targetIntent] as retrieved with [Intent.getAction]. */
+ val targetAction: String? = targetIntent.action,
+
+ /**
+ * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the
+ * canonical "Share" actions. When handling other actions, this flag controls behavioral and
+ * visual changes.
+ */
+ val isSendActionTarget: Boolean = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE),
+
+ /** The top-level content type as retrieved using [Intent.getType]. */
+ val targetType: String? = targetIntent.type,
+
+ /** The package name of the app which started the current activity instance. */
+ val launchedFromPackage: String,
+
+ /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */
+ val title: CharSequence? = null,
+
+ /** A String resource ID to load when [title] is null. */
+ @get:StringRes val defaultTitleResource: Int = 0,
+
+ /**
+ * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER]
+ * or synthesized from callerPackageName. This value is merged into outgoing intents.
+ */
+ val referrer: Uri? = null,
+
+ /**
+ * Choices to exclude from results.
+ *
+ * Any resolved intents with a component in this list will be omitted before presentation.
+ */
+ val filteredComponentNames: List<ComponentName> = emptyList(),
+
+ /**
+ * App provided shortcut share intents (aka "direct share targets")
+ *
+ * Normally share shortcuts are published and consumed using
+ * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow
+ * apps to directly inject the same information.
+ *
+ * Historical note: This option was initially integrated with other results from the
+ * ChooserTargetService API (since deprecated and removed), hence the name and data format.
+ * These are more correctly called "Share Shortcuts" now.
+ */
+ val callerChooserTargets: List<ChooserTarget> = emptyList(),
+
+ /**
+ * Actions the user may perform. These are presented as separate affordances from the main list
+ * of choices. Selecting a choice is a terminal action which results in finishing. The item
+ * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate.
+ */
+ val chooserActions: List<ChooserAction> = emptyList(),
+
+ /**
+ * An action to start an Activity which for user updating of shared content. Selection is a
+ * terminal action, closing the current activity and launching the target of the action.
+ */
+ val modifyShareAction: ChooserAction? = null,
+
+ /**
+ * When false the host activity will be [finished][android.app.Activity.finish] when stopped.
+ */
+ @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false,
+
+ /**
+ * Intents which contain alternate representations of the content being shared. Any results from
+ * resolving these _alternate_ intents are included with the results of the primary intent as
+ * additional choices (e.g. share as image content vs. link to content).
+ */
+ val additionalTargets: List<Intent> = emptyList(),
+
+ /**
+ * Alternate [extras][Intent.getExtras] to substitute when launching a selected app.
+ *
+ * For a given app (by package name), the Bundle describes what parameters to substitute when
+ * that app is selected.
+ *
+ * // TODO: Map<String, Bundle>
+ */
+ val replacementExtras: Bundle? = null,
+
+ /**
+ * App-supplied choices to be presented first in the list.
+ *
+ * Custom labels and icons may be supplied using
+ * [LabeledIntent][android.content.pm.LabeledIntent].
+ *
+ * Limit 2.
+ */
+ val initialIntents: List<Intent> = emptyList(),
+
+ /**
+ * Provides for callers to be notified when a component is selected.
+ *
+ * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the
+ * [ComponentName] of the item.
+ */
+ val chosenComponentSender: IntentSender? = null,
+
+ /**
+ * Provides a mechanism for callers to post-process a target when a selection is made.
+ *
+ * The received intent will contain:
+ * * **EXTRA_INTENT** The chosen target
+ * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target
+ * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a
+ * mechanism for the caller to return information. An updated intent to send must be included
+ * as [Intent.EXTRA_INTENT].
+ */
+ val refinementIntentSender: IntentSender? = null,
+
+ /**
+ * Contains the text content to share supplied by the source app.
+ *
+ * TODO: Constrain length?
+ */
+ val sharedText: CharSequence? = null,
+ /** Contains title to the text content to share supplied by the source app. */
+ val sharedTextTitle: CharSequence? = null,
+
+ /**
+ * Supplied to
+ * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to
+ * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType]
+ * are considered for matching share shortcuts currently.
+ */
+ val shareTargetFilter: IntentFilter? = null,
+
+ /** A URI for additional content */
+ val additionalContentUri: Uri? = null,
+
+ /** Focused item index (from target intent's STREAM_EXTRA) */
+ val focusedItemPosition: Int = 0,
+
+ /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */
+ val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE,
+
+ /**
+ * Metadata to be shown to the user as a part of the sharesheet window.
+ *
+ * Specified by the [Intent.EXTRA_METADATA_TEXT]
+ */
+ val metadataText: CharSequence? = null,
+ val interactiveSessionCallback: IChooserInteractiveSessionCallback? = null,
+) {
+ val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority
+
+ fun getReferrerFillInIntent(): Intent {
+ return Intent().apply {
+ referrerPackage?.also { pkg ->
+ putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg"))
+ }
+ }
+ }
+
+ val payloadIntents = listOf(targetIntent) + additionalTargets
+
+ val callerAllowsTextToggle =
+ screenshotContextUrl() && "com.android.systemui".equals(referrerPackage)
+}
diff --git a/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt
new file mode 100644
index 00000000..7c3188d2
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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.intentresolver.data.repository
+
+import com.android.intentresolver.shared.model.ActivityModel
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import javax.inject.Inject
+import kotlinx.atomicfu.atomic
+
+/** An [ActivityModel] repository that captures the first value. */
+@ActivityRetainedScoped
+class ActivityModelRepository @Inject constructor() {
+ private val _value = atomic<ActivityModel?>(null)
+
+ val value: ActivityModel
+ get() = requireNotNull(_value.value) { "Repository has not been initialized" }
+
+ fun initialize(block: () -> ActivityModel) {
+ if (_value.value == null) {
+ _value.compareAndSet(null, block())
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt
new file mode 100644
index 00000000..8b7885c9
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.data.model.ChooserRequest
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@ViewModelScoped
+class ChooserRequestRepository
+@Inject
+constructor(val initialRequest: ChooserRequest, initialActions: List<CustomActionModel>) {
+ /** All information from the sharing application pertaining to the chooser. */
+ val chooserRequest: MutableStateFlow<ChooserRequest> = MutableStateFlow(initialRequest)
+
+ /** Custom actions from the sharing app to be presented in the chooser. */
+ // NOTE: this could be derived directly from chooserRequest, but that would require working
+ // directly with PendingIntents, which complicates testing.
+ val customActions: MutableStateFlow<List<CustomActionModel>> = MutableStateFlow(initialActions)
+}
diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt
new file mode 100644
index 00000000..7fb3c4cd
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.intentresolver.data.repository
+
+import android.app.admin.DevicePolicyManager
+import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL
+import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB
+import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY
+import android.content.res.Resources
+import androidx.annotation.OpenForTesting
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@OpenForTesting
+@Singleton
+open class DevicePolicyResources
+@Inject
+constructor(
+ @ApplicationOwned private val resources: Resources,
+ devicePolicyManager: DevicePolicyManager,
+) {
+ private val policyResources = devicePolicyManager.resources
+
+ val personalTabLabel by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_PERSONAL_TAB) {
+ resources.getString(R.string.resolver_personal_tab)
+ }
+ )
+ }
+
+ val workTabLabel by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_WORK_TAB) {
+ resources.getString(R.string.resolver_work_tab)
+ }
+ )
+ }
+
+ val personalTabAccessibilityLabel by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) {
+ resources.getString(R.string.resolver_personal_tab_accessibility)
+ }
+ )
+ }
+
+ val workTabAccessibilityLabel by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) {
+ resources.getString(R.string.resolver_work_tab_accessibility)
+ }
+ )
+ }
+
+ val forwardToPersonalMessage: String? =
+ devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) {
+ resources.getString(R.string.forward_intent_to_owner)
+ }
+
+ val forwardToWorkMessage by lazy {
+ requireNotNull(
+ policyResources.getString(FORWARD_INTENT_TO_WORK) {
+ resources.getString(R.string.forward_intent_to_work)
+ }
+ )
+ }
+
+ val noPersonalApps by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_NO_PERSONAL_APPS) {
+ resources.getString(R.string.resolver_no_personal_apps_available)
+ }
+ )
+ }
+
+ val noWorkApps by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_NO_WORK_APPS) {
+ resources.getString(R.string.resolver_no_work_apps_available)
+ }
+ )
+ }
+
+ open val crossProfileBlocked by lazy {
+ requireNotNull(
+ policyResources.getString(RESOLVER_CROSS_PROFILE_BLOCKED_TITLE) {
+ resources.getString(R.string.resolver_cross_profile_blocked)
+ }
+ )
+ }
+
+ open fun toPersonalBlockedByPolicyMessage(share: Boolean): String {
+ return requireNotNull(if (share) {
+ policyResources.getString(RESOLVER_CANT_SHARE_WITH_PERSONAL) {
+ resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation)
+ }
+ } else {
+ policyResources.getString(RESOLVER_CANT_ACCESS_PERSONAL) {
+ resources.getString(R.string.resolver_cant_access_personal_apps_explanation)
+ }
+ })
+ }
+
+ open fun toWorkBlockedByPolicyMessage(share: Boolean): String {
+ return requireNotNull(if (share) {
+ policyResources.getString(RESOLVER_CANT_SHARE_WITH_WORK) {
+ resources.getString(R.string.resolver_cant_share_with_work_apps_explanation)
+ }
+ } else {
+ policyResources.getString(RESOLVER_CANT_ACCESS_WORK) {
+ resources.getString(R.string.resolver_cant_access_work_apps_explanation)
+ }
+ })
+ }
+
+ open fun toPrivateBlockedByPolicyMessage(share: Boolean): String {
+ return if (share) {
+ resources.getString(R.string.resolver_cant_share_with_private_apps_explanation)
+ } else {
+ resources.getString(R.string.resolver_cant_access_private_apps_explanation)
+ }
+ }
+
+ fun getWorkProfileNotSupportedMessage(launcherName: String): String {
+ return requireNotNull(
+ policyResources.getString(
+ RESOLVER_WORK_PROFILE_NOT_SUPPORTED,
+ {
+ resources.getString(
+ R.string.activity_resolver_work_profiles_support,
+ launcherName
+ )
+ },
+ launcherName
+ )
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt
new file mode 100644
index 00000000..753df93e
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import android.content.pm.UserInfo
+import com.android.intentresolver.shared.model.User
+import com.android.intentresolver.shared.model.User.Role
+
+/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */
+fun UserInfo.getSupportedUserRole(): Role? =
+ when {
+ isFull -> Role.PERSONAL
+ isManagedProfile -> Role.WORK
+ isCloneProfile -> Role.CLONE
+ isPrivateProfile -> Role.PRIVATE
+ else -> null
+ }
+
+/**
+ * Creates a [User], based on values from a [UserInfo].
+ *
+ * ```
+ * val users: List<User> =
+ * getEnabledProfiles(user).map(::toUser).filterNotNull()
+ * ```
+ *
+ * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null
+ */
+fun UserInfo.toUser(): User? {
+ return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) }
+}
diff --git a/java/src/com/android/intentresolver/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/data/repository/UserRepository.kt
new file mode 100644
index 00000000..6b5ff4ba
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/UserRepository.kt
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import android.content.Intent
+import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE
+import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE
+import android.content.Intent.ACTION_PROFILE_ADDED
+import android.content.Intent.ACTION_PROFILE_AVAILABLE
+import android.content.Intent.ACTION_PROFILE_REMOVED
+import android.content.Intent.ACTION_PROFILE_UNAVAILABLE
+import android.content.Intent.EXTRA_QUIET_MODE
+import android.content.Intent.EXTRA_USER
+import android.content.IntentFilter
+import android.content.pm.UserInfo
+import android.os.Build
+import android.os.UserHandle
+import android.os.UserManager
+import android.util.Log
+import androidx.annotation.VisibleForTesting
+import com.android.intentresolver.data.BroadcastSubscriber
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.Main
+import com.android.intentresolver.inject.ProfileParent
+import com.android.intentresolver.shared.model.User
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.runningFold
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
+
+interface UserRepository {
+ /**
+ * A [Flow] user profile groups. Each list contains the context user along with all members of
+ * the profile group. This includes the (Full) parent user, if the context user is a profile.
+ */
+ val users: Flow<List<User>>
+
+ /**
+ * A [Flow] of availability. Only profile users may become unavailable.
+ *
+ * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled].
+ */
+ val availability: Flow<Map<User, Boolean>>
+
+ /**
+ * Request that availability be updated to the requested state. This currently includes toggling
+ * quiet mode as needed. This may involve additional background actions, such as starting or
+ * stopping a profile user (along with their many associated processes).
+ *
+ * If successful, the change will be applied after the call returns and can be observed using
+ * [UserRepository.availability] for the given user.
+ *
+ * No actions are taken if the user is already in requested state.
+ *
+ * @throws IllegalArgumentException if called for an unsupported user type
+ */
+ suspend fun requestState(user: User, available: Boolean)
+}
+
+private const val TAG = "UserRepository"
+
+/** The delay between entering the cached process state and entering the frozen cgroup */
+private val cachedProcessFreezeDelay: Duration = 10.seconds
+
+/** How long to continue listening for user state broadcasts while unsubscribed */
+private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds
+
+/** How long to retain the previous user state after the state flow stops. */
+private val stateCacheTimeout = 2.seconds
+
+internal data class UserWithState(val user: User, val available: Boolean)
+
+internal typealias UserStates = List<UserWithState>
+
+internal val userBroadcastActions =
+ setOf(
+ ACTION_PROFILE_ADDED,
+ ACTION_PROFILE_REMOVED,
+
+ // Quiet mode enabled/disabled for managed
+ // From: UserController.broadcastProfileAvailabilityChanges
+ // In response to setQuietModeEnabled
+ ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only
+ ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only
+
+ // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile
+ // true'
+ ACTION_PROFILE_AVAILABLE, // quiet mode,
+ ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type
+ )
+
+/** Tracks and publishes state for the parent user and associated profiles. */
+class UserRepositoryImpl
+@VisibleForTesting
+constructor(
+ private val profileParent: UserHandle,
+ private val userManager: UserManager,
+ /** A flow of events which represent user-state changes from [UserManager]. */
+ private val userEvents: Flow<UserEvent>,
+ scope: CoroutineScope,
+ private val backgroundDispatcher: CoroutineDispatcher,
+) : UserRepository {
+ @Inject
+ constructor(
+ @ProfileParent profileParent: UserHandle,
+ userManager: UserManager,
+ @Main scope: CoroutineScope,
+ @Background background: CoroutineDispatcher,
+ broadcastSubscriber: BroadcastSubscriber,
+ ) : this(
+ profileParent,
+ userManager,
+ userEvents =
+ broadcastSubscriber.createFlow(
+ createFilter(userBroadcastActions),
+ profileParent,
+ Intent::toUserEvent
+ ),
+ scope,
+ background,
+ )
+
+ private fun debugLog(msg: () -> String) {
+ if (Build.IS_USERDEBUG || Build.IS_ENG) {
+ Log.d(TAG, msg())
+ }
+ }
+
+ private fun errorLog(msg: String, caught: Throwable? = null) {
+ Log.e(TAG, msg, caught)
+ }
+
+ /**
+ * An exception which indicates that an inconsistency exists between the user state map and the
+ * rest of the system.
+ */
+ private class UserStateException(
+ override val message: String,
+ val event: UserEvent,
+ override val cause: Throwable? = null,
+ ) : RuntimeException("$message: event=$event", cause)
+
+ private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher)
+ private val usersWithState: Flow<UserStates> =
+ userEvents
+ .onStart { emit(Initialize) }
+ .onEach { debugLog { "userEvent: $it" } }
+ .runningFold(emptyList(), ::handleEvent)
+ .distinctUntilChanged()
+ .onEach { debugLog { "userStateList: $it" } }
+ .stateIn(
+ sharingScope,
+ started =
+ WhileSubscribed(
+ stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds,
+ replayExpirationMillis = 0
+ /** Immediately on stop */
+ ),
+ listOf()
+ )
+ .filterNot { it.isEmpty() }
+
+ private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates {
+ return try {
+ // Handle an action by performing some operation, then returning a new map
+ when (event) {
+ is Initialize -> createNewUserStates(profileParent)
+ is ProfileAdded -> handleProfileAdded(event, users)
+ is ProfileRemoved -> handleProfileRemoved(event, users)
+ is AvailabilityChange -> handleAvailability(event, users)
+ is UnknownEvent -> {
+ debugLog { "Unhandled event: $event)" }
+ users
+ }
+ }
+ } catch (e: UserStateException) {
+ errorLog("An error occurred handling an event: ${e.event}")
+ errorLog("Attempting to recover...", e)
+ createNewUserStates(profileParent)
+ }
+ }
+
+ override val users: Flow<List<User>> =
+ usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged()
+
+ override val availability: Flow<Map<User, Boolean>> =
+ usersWithState
+ .map { list -> list.associate { it.user to it.available } }
+ .distinctUntilChanged()
+
+ override suspend fun requestState(user: User, available: Boolean) {
+ return withContext(backgroundDispatcher) {
+ debugLog { "requestQuietModeEnabled: ${!available} for user $user" }
+ userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle)
+ }
+ }
+
+ private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) =
+ filter { it.user.id != handle.identifier } + user
+
+ private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates {
+ val userEntry =
+ current.firstOrNull { it.user.id == event.user.identifier }
+ ?: throw UserStateException("User was not present in the map", event)
+ return current.update(event.user, userEntry.copy(available = !event.quietMode))
+ }
+
+ private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates {
+ if (!current.any { it.user.id == event.user.identifier }) {
+ throw UserStateException("User was not present in the map", event)
+ }
+ return current.filter { it.user.id != event.user.identifier }
+ }
+
+ private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates {
+ val user =
+ try {
+ requireNotNull(readUser(event.user))
+ } catch (e: Exception) {
+ throw UserStateException("Failed to read user from UserManager", event, e)
+ }
+ return current + UserWithState(user, true)
+ }
+
+ private suspend fun createNewUserStates(user: UserHandle): UserStates {
+ val profiles = readProfileGroup(user)
+ return profiles.mapNotNull { userInfo ->
+ userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) }
+ }
+ }
+
+ private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> {
+ return withContext(backgroundDispatcher) {
+ @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier)
+ }
+ .toList()
+ }
+
+ /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */
+ private suspend fun readUser(user: UserHandle): User? {
+ val userInfo =
+ withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) }
+ return userInfo?.let { info ->
+ info.getSupportedUserRole()?.let { role -> User(info.id, role) }
+ }
+ }
+}
+
+/** A Model representing changes to profiles and availability */
+sealed interface UserEvent
+
+/** Used as a an initial value to trigger a fetch of all profile data. */
+data object Initialize : UserEvent
+
+/** A profile was added to the profile group. */
+data class ProfileAdded(
+ /** The handle for the added profile. */
+ val user: UserHandle,
+) : UserEvent
+
+/** A profile was removed from the profile group. */
+data class ProfileRemoved(
+ /** The handle for the removed profile. */
+ val user: UserHandle,
+) : UserEvent
+
+/** A profile has changed availability. */
+data class AvailabilityChange(
+ /** THe handle for the profile with availability change. */
+ val user: UserHandle,
+ /** The new quietMode state. */
+ val quietMode: Boolean = false,
+) : UserEvent
+
+/** An unhandled event, logged and ignored. */
+data class UnknownEvent(
+ /** The broadcast intent action received */
+ val action: String?,
+) : UserEvent
+
+/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */
+internal fun Intent.toUserEvent(): UserEvent {
+ val action = action
+ val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java)
+ val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false)
+ return when (action) {
+ ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user))
+ ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user))
+ ACTION_MANAGED_PROFILE_UNAVAILABLE,
+ ACTION_MANAGED_PROFILE_AVAILABLE,
+ ACTION_PROFILE_AVAILABLE,
+ ACTION_PROFILE_UNAVAILABLE ->
+ AvailabilityChange(requireNotNull(user), requireNotNull(quietMode))
+ else -> UnknownEvent(action)
+ }
+}
+
+internal fun createFilter(actions: Iterable<String>): IntentFilter {
+ return IntentFilter().apply { actions.forEach(::addAction) }
+}
+
+internal fun UserInfo?.isAvailable(): Boolean {
+ return this?.isQuietModeEnabled != true
+}
diff --git a/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt
new file mode 100644
index 00000000..7109d6d4
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import android.content.Context
+import android.os.UserHandle
+import android.os.UserManager
+import com.android.intentresolver.inject.ApplicationUser
+import com.android.intentresolver.inject.ProfileParent
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface UserRepositoryModule {
+ companion object {
+ @Provides
+ @Singleton
+ @ApplicationUser
+ fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user
+
+ @Provides
+ @Singleton
+ @ProfileParent
+ fun profileParent(
+ @ApplicationContext context: Context,
+ userManager: UserManager
+ ): UserHandle {
+ return userManager.getProfileParent(context.user) ?: context.user
+ }
+ }
+
+ @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository
+}
diff --git a/java/src/com/android/intentresolver/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt
new file mode 100644
index 00000000..10a33eb1
--- /dev/null
+++ b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import android.content.Context
+import android.os.UserHandle
+import androidx.core.content.getSystemService
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlin.reflect.KClass
+
+/**
+ * Provides instances of a [system service][Context.getSystemService] created with
+ * [the context of a specified user][Context.createContextAsUser].
+ *
+ * Some services which have only `@UserHandleAware` APIs operate on the user id available from
+ * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user
+ * API model to work in multi-user manner.
+ *
+ * Example usage:
+ * ```
+ * @Provides
+ * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> {
+ * return UserScopedServiceImpl(ctx, UserManager::class)
+ * }
+ *
+ * class MyUserHelper @Inject constructor(
+ * private val userMgr: UserScopedService<UserManager>,
+ * ) {
+ * fun isPrivateProfile(user: UserHandle): UserManager {
+ * return userMgr.forUser(user).isPrivateProfile()
+ * }
+ * }
+ * ```
+ */
+fun interface UserScopedService<T> {
+ /** Create a service instance for the given user. */
+ fun forUser(user: UserHandle): T
+}
+
+class UserScopedServiceImpl<T : Any>(
+ @ApplicationContext private val context: Context,
+ private val serviceType: KClass<T>,
+) : UserScopedService<T> {
+ override fun forUser(user: UserHandle): T {
+ val context =
+ if (context.user == user) {
+ context
+ } else {
+ context.createContextAsUser(user, 0)
+ }
+ return requireNotNull(context.getSystemService(serviceType.java))
+ }
+}
diff --git a/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt b/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt
new file mode 100644
index 00000000..5ca3ad20
--- /dev/null
+++ b/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.domain
+
+import android.content.Intent
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_METADATA_TEXT
+import android.os.Bundle
+import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault
+import com.android.intentresolver.data.model.ChooserRequest
+
+/** Creates a new ChooserRequest with the target intent and updates from a Shareousel callback */
+fun ChooserRequest.updateWith(targetIntent: Intent, update: ShareouselUpdate): ChooserRequest =
+ copy(
+ targetIntent = targetIntent,
+ callerChooserTargets = update.callerTargets.getOrDefault(callerChooserTargets),
+ modifyShareAction = update.modifyShareAction.getOrDefault(modifyShareAction),
+ additionalTargets = update.alternateIntents.getOrDefault(additionalTargets),
+ chosenComponentSender = update.resultIntentSender.getOrDefault(chosenComponentSender),
+ refinementIntentSender = update.refinementIntentSender.getOrDefault(refinementIntentSender),
+ metadataText = update.metadataText.getOrDefault(metadataText),
+ chooserActions = update.customActions.getOrDefault(chooserActions),
+ filteredComponentNames =
+ if (shareouselUpdateExcludeComponentsExtra()) {
+ update.excludeComponents.getOrDefault(filteredComponentNames)
+ } else {
+ filteredComponentNames
+ },
+ )
+
+/** Save ChooserRequest values that can be updated by the Shareousel into a Bundle */
+fun ChooserRequest.saveUpdates(bundle: Bundle): Bundle {
+ bundle.putParcelable(EXTRA_INTENT, targetIntent)
+ bundle.putParcelableArray(EXTRA_CHOOSER_TARGETS, callerChooserTargets.toTypedArray())
+ bundle.putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShareAction)
+ bundle.putParcelableArray(EXTRA_ALTERNATE_INTENTS, additionalTargets.toTypedArray())
+ bundle.putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, chosenComponentSender)
+ bundle.putParcelable(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, chosenComponentSender)
+ bundle.putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, refinementIntentSender)
+ bundle.putCharSequence(EXTRA_METADATA_TEXT, metadataText)
+ bundle.putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions.toTypedArray())
+ if (shareouselUpdateExcludeComponentsExtra()) {
+ bundle.putParcelableArray(EXTRA_EXCLUDE_COMPONENTS, filteredComponentNames.toTypedArray())
+ }
+ return bundle
+}
diff --git a/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt
new file mode 100644
index 00000000..2392a48d
--- /dev/null
+++ b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.domain.interactor
+
+import android.os.UserHandle
+import com.android.intentresolver.data.repository.UserRepository
+import com.android.intentresolver.inject.ApplicationUser
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.shared.model.Profile.Type
+import com.android.intentresolver.shared.model.User
+import com.android.intentresolver.shared.model.User.Role
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+
+/** The high level User interface. */
+class UserInteractor
+@Inject
+constructor(
+ private val userRepository: UserRepository,
+ /** The specific [User] of the application which started this one. */
+ @ApplicationUser val launchedAs: UserHandle,
+) {
+ /** The profile group associated with the launching app user. */
+ val profiles: Flow<List<Profile>> =
+ userRepository.users.map { users ->
+ users.mapNotNull { user ->
+ when (user.role) {
+ // PERSONAL includes CLONE
+ Role.PERSONAL -> {
+ Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE })
+ }
+ Role.CLONE -> {
+ /* ignore, included above */
+ null
+ }
+ // others map 1:1
+ else -> Profile(profileFromRole(user.role), user)
+ }
+ }
+ }
+
+ /** The [Profile] of the application which started this one. */
+ val launchedAsProfile: Flow<Profile> =
+ profiles.map { profiles ->
+ // The launching user profile is the one with a primary id or clone id
+ // matching the application user id. By definition there must always be exactly
+ // one matching profile for the current user.
+ profiles.single {
+ it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier
+ }
+ }
+ /**
+ * Provides a flow to report on the availability of profile. An unavailable profile may be
+ * hidden or appear disabled within the app.
+ */
+ val availability: Flow<Map<Profile, Boolean>> =
+ combine(profiles, userRepository.availability) { profiles, availability ->
+ profiles.associateWith { availability.getOrDefault(it.primary, false) }
+ }
+
+ /**
+ * Request the profile state be updated. In the case of enabling, the operation could take
+ * significant time and/or require user input.
+ */
+ suspend fun updateState(profile: Profile, available: Boolean) {
+ userRepository.requestState(profile.primary, available)
+ }
+
+ private fun profileFromRole(role: Role): Type =
+ when (role) {
+ Role.PERSONAL -> Type.PERSONAL
+ Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */
+ Role.PRIVATE -> Type.PRIVATE
+ Role.WORK -> Type.WORK
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt
index b9047712..05062a4b 100644
--- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
+++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt
@@ -13,19 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package com.android.intentresolver.emptystate
-package com.android.intentresolver
+import com.android.intentresolver.ResolverListAdapter
-import com.android.intentresolver.flags.FeatureFlagRepository
-import com.android.systemui.flags.BooleanFlag
-import com.android.systemui.flags.ReleasedFlag
-import com.android.systemui.flags.UnreleasedFlag
-
-class TestFeatureFlagRepository(
- private val overrides: Map<BooleanFlag, Boolean>
-) : FeatureFlagRepository {
- override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag)
- override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag)
+/**
+ * Empty state provider that combines multiple providers. Providers earlier in the list have
+ * priority, that is if there is a provider that returns non-null empty state then all further
+ * providers will be ignored.
+ */
+class CompositeEmptyStateProvider(
+ private vararg val providers: EmptyStateProvider,
+) : EmptyStateProvider {
- private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default)
+ override fun getEmptyState(resolverListAdapter: ResolverListAdapter): EmptyState? {
+ return providers.firstNotNullOfOrNull { it.getEmptyState(resolverListAdapter) }
+ }
}
diff --git a/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java
new file mode 100644
index 00000000..2164e533
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java
@@ -0,0 +1,59 @@
+/*
+ * 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.intentresolver.emptystate;
+
+import android.annotation.NonNull;
+import android.annotation.UserIdInt;
+import android.app.AppGlobals;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.content.pm.IPackageManager;
+
+import com.android.intentresolver.IntentForwarderActivity;
+
+import java.util.List;
+
+/**
+ * Utility class to check if there are cross profile intents, it is in a separate class so
+ * it could be mocked in tests
+ */
+public class CrossProfileIntentsChecker {
+
+ private final ContentResolver mContentResolver;
+ private final IPackageManager mPackageManager;
+
+ public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) {
+ this(contentResolver, AppGlobals.getPackageManager());
+ }
+
+ CrossProfileIntentsChecker(
+ @NonNull ContentResolver contentResolver, IPackageManager packageManager) {
+ mContentResolver = contentResolver;
+ mPackageManager = packageManager;
+ }
+
+ /**
+ * Returns {@code true} if at least one of the provided {@code intents} can be forwarded
+ * from {@code source} (user id) to {@code target} (user id).
+ */
+ public boolean hasCrossProfileIntents(
+ List<Intent> intents, @UserIdInt int source, @UserIdInt int target) {
+ return intents.stream().anyMatch(intent ->
+ null != IntentForwarderActivity.canForward(intent, source, target,
+ mPackageManager, mContentResolver));
+ }
+}
+
diff --git a/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt
new file mode 100644
index 00000000..ea1a03cc
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.emptystate
+
+class DefaultEmptyState : EmptyState {
+ override fun useDefaultEmptyView() = true
+}
diff --git a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java
new file mode 100644
index 00000000..1cbc6175
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.emptystate;
+
+import android.app.admin.DevicePolicyEventLogger;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Empty state that gets strings from the device policy manager and tracks events into
+ * event logger of the device policy events.
+ */
+public class DevicePolicyBlockerEmptyState implements EmptyState {
+ private final String mTitle;
+ private final String mSubtitle;
+ private final int mEventId;
+ private final String mEventCategory;
+
+ public DevicePolicyBlockerEmptyState(
+ String title,
+ String subtitle,
+ int devicePolicyEventId,
+ String devicePolicyEventCategory) {
+ mTitle = title;
+ mSubtitle = subtitle;
+ mEventId = devicePolicyEventId;
+ mEventCategory = devicePolicyEventCategory;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ @Nullable
+ @Override
+ public String getSubtitle() {
+ return mSubtitle;
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ if (mEventId != -1) {
+ DevicePolicyEventLogger.createEvent(mEventId)
+ .setStrings(mEventCategory)
+ .write();
+ }
+ }
+
+ @Override
+ public boolean shouldSkipDataRebuild() {
+ return true;
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/EmptyState.java b/java/src/com/android/intentresolver/emptystate/EmptyState.java
new file mode 100644
index 00000000..cde99fe1
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/EmptyState.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.intentresolver.emptystate;
+
+import android.annotation.Nullable;
+
+/**
+ * Model for the "empty state"/"blocker" UI to display instead of a profile tab's normal contents.
+ */
+public interface EmptyState {
+ /**
+ * Get the title to show on the empty state.
+ */
+ @Nullable
+ default String getTitle() {
+ return null;
+ }
+
+ /**
+ * Get the subtitle string to show underneath the title on the empty state.
+ */
+ @Nullable
+ default String getSubtitle() {
+ return null;
+ }
+
+ /**
+ * Get the handler for an optional button associated with this empty state. If the result is
+ * non-null, the empty-state UI will be built with a button that dispatches this handler.
+ */
+ @Nullable
+ default ClickListener getButtonClickListener() {
+ return null;
+ }
+
+ /**
+ * Get whether to show the default UI for the empty state. If true, the UI will show the default
+ * blocker text ('No apps can perform this action') and style; title and subtitle are ignored.
+ */
+ default boolean useDefaultEmptyView() {
+ return false;
+ }
+
+ /**
+ * Returns true if for this empty state we should skip rebuilding of the apps list
+ * for this tab.
+ */
+ default boolean shouldSkipDataRebuild() {
+ return false;
+ }
+
+ /**
+ * Called when empty state is shown, could be used e.g. to track analytics events.
+ */
+ default void onEmptyStateShown() {}
+
+ interface ClickListener {
+ void onClick(TabControl currentTab);
+ }
+
+ interface TabControl {
+ void showSpinner();
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java
new file mode 100644
index 00000000..c3261287
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java
@@ -0,0 +1,37 @@
+/*
+ * 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.intentresolver.emptystate;
+
+import android.annotation.Nullable;
+
+import com.android.intentresolver.ResolverListAdapter;
+
+/**
+ * Returns an empty state to show for the current profile page (tab) if necessary.
+ * This could be used e.g. to show a blocker on a tab if device management policy doesn't
+ * allow to use it or there are no apps available.
+ */
+public interface EmptyStateProvider {
+ /**
+ * When a non-null empty state is returned the corresponding profile page will show
+ * this empty state
+ * @param resolverListAdapter the current adapter
+ */
+ @Nullable
+ default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ return null;
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java
new file mode 100644
index 00000000..7524f343
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java
@@ -0,0 +1,136 @@
+/*
+ * 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.intentresolver.emptystate;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+/**
+ * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by
+ * some empty-state status.
+ */
+public class EmptyStateUiHelper {
+ private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier;
+ private final View mEmptyStateView;
+ private final View mListView;
+ private final View mEmptyStateContainerView;
+ private final TextView mEmptyStateTitleView;
+ private final TextView mEmptyStateSubtitleView;
+ private final Button mEmptyStateButtonView;
+ private final View mEmptyStateProgressView;
+ private final View mEmptyStateEmptyView;
+
+ public EmptyStateUiHelper(
+ ViewGroup rootView,
+ int listViewResourceId,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier;
+ mEmptyStateView =
+ rootView.requireViewById(com.android.internal.R.id.resolver_empty_state);
+ mListView = rootView.requireViewById(listViewResourceId);
+ mEmptyStateContainerView = mEmptyStateView.requireViewById(
+ com.android.internal.R.id.resolver_empty_state_container);
+ mEmptyStateTitleView = mEmptyStateView.requireViewById(
+ com.android.internal.R.id.resolver_empty_state_title);
+ mEmptyStateSubtitleView = mEmptyStateView.requireViewById(
+ com.android.internal.R.id.resolver_empty_state_subtitle);
+ mEmptyStateButtonView = mEmptyStateView.requireViewById(
+ com.android.internal.R.id.resolver_empty_state_button);
+ mEmptyStateProgressView = mEmptyStateView.requireViewById(
+ com.android.internal.R.id.resolver_empty_state_progress);
+ mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty);
+ }
+
+ /**
+ * Display the described empty state.
+ * @param emptyState the data describing the cause of this empty-state condition.
+ * @param buttonOnClick handler for a button that the user might be able to use to circumvent
+ * the empty-state condition. If null, no button will be displayed.
+ */
+ public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) {
+ resetViewVisibilities();
+ setupContainerPadding();
+
+ String title = emptyState.getTitle();
+ if (title != null) {
+ mEmptyStateTitleView.setVisibility(View.VISIBLE);
+ mEmptyStateTitleView.setText(title);
+ } else {
+ mEmptyStateTitleView.setVisibility(View.GONE);
+ }
+
+ String subtitle = emptyState.getSubtitle();
+ if (subtitle != null) {
+ mEmptyStateSubtitleView.setVisibility(View.VISIBLE);
+ mEmptyStateSubtitleView.setText(subtitle);
+ } else {
+ mEmptyStateSubtitleView.setVisibility(View.GONE);
+ }
+
+ mEmptyStateEmptyView.setVisibility(
+ emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE);
+ // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the
+ // state's specified title/subtitle; where (if anywhere) is that implemented?
+
+ mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE);
+ mEmptyStateButtonView.setOnClickListener(buttonOnClick);
+
+ // Don't show the main list view when we're showing an empty state.
+ mListView.setVisibility(View.GONE);
+ }
+
+ /** Sets up the padding of the view containing the empty state screens. */
+ public void setupContainerPadding() {
+ Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get();
+ bottomPaddingOverride.ifPresent(paddingBottom ->
+ mEmptyStateContainerView.setPadding(
+ mEmptyStateContainerView.getPaddingLeft(),
+ mEmptyStateContainerView.getPaddingTop(),
+ mEmptyStateContainerView.getPaddingRight(),
+ paddingBottom));
+ }
+
+ public void showSpinner() {
+ mEmptyStateTitleView.setVisibility(View.INVISIBLE);
+ // TODO: subtitle?
+ mEmptyStateButtonView.setVisibility(View.INVISIBLE);
+ mEmptyStateProgressView.setVisibility(View.VISIBLE);
+ mEmptyStateEmptyView.setVisibility(View.GONE);
+ }
+
+ public void hide() {
+ mEmptyStateView.setVisibility(View.GONE);
+ mListView.setVisibility(View.VISIBLE);
+ }
+
+ // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us
+ // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and
+ // we could consider setting up narrower "realistic" preconditions to make assertions about the
+ // higher-level operation.
+ public void resetViewVisibilities() {
+ mEmptyStateTitleView.setVisibility(View.VISIBLE);
+ mEmptyStateSubtitleView.setVisibility(View.VISIBLE);
+ mEmptyStateButtonView.setVisibility(View.INVISIBLE);
+ mEmptyStateProgressView.setVisibility(View.GONE);
+ mEmptyStateEmptyView.setVisibility(View.GONE);
+ mEmptyStateView.setVisibility(View.VISIBLE);
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java
new file mode 100644
index 00000000..b03c730a
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.emptystate;
+
+import android.app.admin.DevicePolicyEventLogger;
+import android.stats.devicepolicy.nano.DevicePolicyEnums;
+
+import androidx.annotation.NonNull;
+
+public class NoAppsAvailableEmptyState implements EmptyState {
+
+ @NonNull
+ private final String mTitle;
+
+ @NonNull
+ private final String mMetricsCategory;
+
+ private final boolean mIsPersonalProfile;
+
+ public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory,
+ boolean isPersonalProfile) {
+ mTitle = title;
+ mMetricsCategory = metricsCategory;
+ mIsPersonalProfile = isPersonalProfile;
+ }
+
+ @NonNull
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger.createEvent(
+ DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED)
+ .setStrings(mMetricsCategory)
+ .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile)
+ .write();
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java
new file mode 100644
index 00000000..b3d3e343
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2022 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.intentresolver.emptystate;
+
+
+import static com.android.intentresolver.shared.model.Profile.Type.PERSONAL;
+
+import static java.util.Objects.requireNonNull;
+
+import android.os.UserHandle;
+
+import androidx.annotation.NonNull;
+
+import com.android.intentresolver.ProfileAvailability;
+import com.android.intentresolver.ProfileHelper;
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.shared.model.Profile;
+import com.android.intentresolver.ui.ProfilePagerResources;
+
+/**
+ * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
+ * there are no apps available.
+ */
+public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider {
+
+ @NonNull private final String mMetricsCategory;
+ private final ProfilePagerResources mProfilePagerResources;
+ private final ProfileHelper mProfileHelper;
+ private final ProfileAvailability mProfileAvailability;
+
+ public NoAppsAvailableEmptyStateProvider(
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability,
+ @NonNull String metricsCategory,
+ ProfilePagerResources profilePagerResources) {
+ mProfileHelper = profileHelper;
+ mProfileAvailability = profileAvailability;
+ mMetricsCategory = metricsCategory;
+ mProfilePagerResources = profilePagerResources;
+ }
+
+ @NonNull
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ UserHandle listUserHandle = resolverListAdapter.getUserHandle();
+ if (mProfileAvailability.visibleProfileCount() == 1) {
+ return new DefaultEmptyState();
+ } else {
+ Profile.Type profileType =
+ requireNonNull(mProfileHelper.findProfileType(listUserHandle));
+ String title = mProfilePagerResources.noAppsMessage(profileType);
+ return new NoAppsAvailableEmptyState(
+ title,
+ mMetricsCategory,
+ /* isPersonalProfile= */ profileType == PERSONAL
+ );
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java
new file mode 100644
index 00000000..0cf2ea45
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2022 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.intentresolver.emptystate;
+
+import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL;
+import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK;
+
+import static com.android.intentresolver.ChooserActivity.METRICS_CATEGORY_CHOOSER;
+
+import static java.util.Objects.requireNonNull;
+
+import android.content.Intent;
+
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ProfileHelper;
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.data.repository.DevicePolicyResources;
+import com.android.intentresolver.shared.model.Profile;
+
+import java.util.List;
+
+/**
+ * Empty state provider that informs about a lack of cross profile sharing. It will return
+ * an empty state in case there are no intents which can be forwarded to another profile.
+ */
+public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider {
+
+ private final ProfileHelper mProfileHelper;
+ private final DevicePolicyResources mDevicePolicyResources;
+ private final boolean mIsShare;
+ private final CrossProfileIntentsChecker mCrossProfileIntentsChecker;
+
+ public NoCrossProfileEmptyStateProvider(
+ ProfileHelper profileHelper,
+ DevicePolicyResources devicePolicyResources,
+ CrossProfileIntentsChecker crossProfileIntentsChecker,
+ boolean isShare) {
+ mProfileHelper = profileHelper;
+ mDevicePolicyResources = devicePolicyResources;
+ mIsShare = isShare;
+ mCrossProfileIntentsChecker = crossProfileIntentsChecker;
+ }
+
+ private boolean hasCrossProfileIntents(List<Intent> intents, Profile source, Profile target) {
+ if (source.getPrimary().getHandle().equals(target.getPrimary().getHandle())) {
+ return true;
+ }
+ // Note: Use of getPrimary() here also handles delegation of CLONE profile to parent.
+ return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents,
+ source.getPrimary().getId(), target.getPrimary().getId());
+ }
+
+ @Nullable
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter adapter) {
+ Profile launchedBy = mProfileHelper.getLaunchedAsProfile();
+ Profile tabOwner = requireNonNull(mProfileHelper.findProfile(adapter.getUserHandle()));
+
+ // When sharing into or out of Private profile, perform the check using the parent profile
+ // instead. (Hard-coded application of CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT)
+
+ Profile effectiveSource = launchedBy;
+ Profile effectiveTarget = tabOwner;
+
+ // Assumption baked into design: "Personal" profile is the parent of all other profiles.
+ if (launchedBy.getType() == Profile.Type.PRIVATE) {
+ effectiveSource = mProfileHelper.getPersonalProfile();
+ }
+
+ if (tabOwner.getType() == Profile.Type.PRIVATE) {
+ effectiveTarget = mProfileHelper.getPersonalProfile();
+ }
+
+ // Allow access to the tab when there is at least one target permitted to cross profiles.
+ if (hasCrossProfileIntents(adapter.getIntents(), effectiveSource, effectiveTarget)) {
+ return null;
+ }
+
+ switch (tabOwner.getType()) {
+ case PERSONAL:
+ return new DevicePolicyBlockerEmptyState(
+ mDevicePolicyResources.getCrossProfileBlocked(),
+ mDevicePolicyResources.toPersonalBlockedByPolicyMessage(mIsShare),
+ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
+ METRICS_CATEGORY_CHOOSER);
+
+ case WORK:
+ return new DevicePolicyBlockerEmptyState(
+ mDevicePolicyResources.getCrossProfileBlocked(),
+ mDevicePolicyResources.toWorkBlockedByPolicyMessage(mIsShare),
+ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
+ METRICS_CATEGORY_CHOOSER);
+
+ case PRIVATE:
+ return new DevicePolicyBlockerEmptyState(
+ mDevicePolicyResources.getCrossProfileBlocked(),
+ mDevicePolicyResources.toPrivateBlockedByPolicyMessage(mIsShare),
+ /* Suppress log event. TODO: Define a new metrics event for this? */ -1,
+ METRICS_CATEGORY_CHOOSER);
+ }
+ return null;
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java
new file mode 100644
index 00000000..e9de3221
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.emptystate;
+
+import android.app.admin.DevicePolicyEventLogger;
+import android.stats.devicepolicy.nano.DevicePolicyEnums;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class WorkProfileOffEmptyState implements EmptyState {
+
+ private final String mTitle;
+ private final ClickListener mOnClick;
+ private final String mMetricsCategory;
+
+ public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick,
+ @NonNull String metricsCategory) {
+ mTitle = title;
+ mOnClick = onClick;
+ mMetricsCategory = metricsCategory;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ @Nullable
+ @Override
+ public ClickListener getButtonClickListener() {
+ return mOnClick;
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED)
+ .setStrings(mMetricsCategory)
+ .write();
+ }
+}
diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java
new file mode 100644
index 00000000..f78d1ca2
--- /dev/null
+++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2022 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.intentresolver.emptystate;
+
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
+
+import static java.util.Objects.requireNonNull;
+
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.os.UserHandle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ProfileAvailability;
+import com.android.intentresolver.ProfileHelper;
+import com.android.intentresolver.R;
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener;
+import com.android.intentresolver.shared.model.Profile;
+
+/**
+ * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
+ * work profile is paused and we need to show a button to enable it.
+ */
+public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
+
+ private final ProfileHelper mProfileHelper;
+ private final ProfileAvailability mProfileAvailability;
+ private final String mMetricsCategory;
+ private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+ private final Context mContext;
+
+ public WorkProfilePausedEmptyStateProvider(@NonNull Context context,
+ ProfileHelper profileHelper,
+ ProfileAvailability profileAvailability,
+ @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
+ @NonNull String metricsCategory) {
+ mContext = context;
+ mProfileHelper = profileHelper;
+ mProfileAvailability = profileAvailability;
+ mMetricsCategory = metricsCategory;
+ mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
+ }
+
+ @Nullable
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ UserHandle userHandle = resolverListAdapter.getUserHandle();
+ if (!mProfileHelper.getWorkProfilePresent()) {
+ return null;
+ }
+ Profile workProfile = requireNonNull(mProfileHelper.getWorkProfile());
+
+ // Policy: only show the "Work profile paused" state when:
+ // * provided list adapter is from the work profile
+ // * the list adapter is not empty
+ // * work profile quiet mode is _enabled_ (unavailable)
+
+ if (!userHandle.equals(workProfile.getPrimary().getHandle())
+ || resolverListAdapter.getCount() == 0
+ || mProfileAvailability.isAvailable(workProfile)) {
+ return null;
+ }
+
+ String title = mContext.getSystemService(DevicePolicyManager.class)
+ .getResources().getString(RESOLVER_WORK_PAUSED_TITLE,
+ () -> mContext.getString(R.string.resolver_turn_on_work_apps));
+
+ return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> {
+ tab.showSpinner();
+ if (mOnSwitchOnWorkSelectedListener != null) {
+ mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+ }
+ mProfileAvailability.requestQuietModeState(workProfile, false);
+ }, mMetricsCategory);
+ }
+
+}
diff --git a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt
new file mode 100644
index 00000000..5635ec28
--- /dev/null
+++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.bundleOf
+import androidx.lifecycle.DEFAULT_ARGS_KEY
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.lifecycle.viewmodel.MutableCreationExtras
+
+/**
+ * Returns a new instance with additional [values] added to the existing default args Bundle (if
+ * present), otherwise adds a new entry with a copy of this bundle.
+ */
+fun CreationExtras.addDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras {
+ val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle()
+ defaultArgs.putAll(bundleOf(*values))
+ return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) }
+}
+
+fun CreationExtras.replaceDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras {
+ val mutableExtras = if (this is MutableCreationExtras) this else MutableCreationExtras(this)
+ mutableExtras[DEFAULT_ARGS_KEY] = bundleOf(*values)
+ return mutableExtras
+}
diff --git a/java/src/com/android/intentresolver/ext/IntentExt.kt b/java/src/com/android/intentresolver/ext/IntentExt.kt
new file mode 100644
index 00000000..127dbf86
--- /dev/null
+++ b/java/src/com/android/intentresolver/ext/IntentExt.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.content.Intent
+import java.util.function.Predicate
+
+/** Applies an operation on this Intent if matches the given filter. */
+inline fun Intent.ifMatch(
+ predicate: Predicate<Intent>,
+ crossinline block: Intent.() -> Unit
+): Intent {
+ if (predicate.test(this)) {
+ apply(block)
+ }
+ return this
+}
+
+/** True if the Intent has one of the specified actions. */
+fun Intent.hasAction(vararg actions: String): Boolean = action in actions
+
+/** True if the Intent has a specific component target */
+fun Intent.hasComponent(): Boolean = (component != null)
+
+/** True if the Intent has a single matching category. */
+fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category
+
+/** True if the Intent is a SEND or SEND_MULTIPLE action. */
+fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
+
+/** True if the Intent resolves to the special Home (Launcher) component */
+fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME)
diff --git a/java/src/com/android/intentresolver/ext/ParcelExt.kt b/java/src/com/android/intentresolver/ext/ParcelExt.kt
new file mode 100644
index 00000000..68ea600f
--- /dev/null
+++ b/java/src/com/android/intentresolver/ext/ParcelExt.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.os.Parcel
+
+inline fun <reified T> Parcel.requireParcelable(): T {
+ return requireNotNull(readParcelable<T>()) { "A non-value required from this parcel was null!" }
+}
+
+inline fun <reified T> Parcel.readParcelable(): T? {
+ return readParcelable(T::class.java.classLoader, T::class.java)
+}
diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
deleted file mode 100644
index d1494fe7..00000000
--- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver.flags
-
-import android.provider.DeviceConfig
-import com.android.systemui.flags.ParcelableFlag
-
-internal class DeviceConfigProxy {
- fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? {
- return runCatching {
- val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null
- if (hasProperty) {
- DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default)
- } else {
- null
- }
- }.getOrDefault(null)
- }
-}
diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt
deleted file mode 100644
index 2c20d341..00000000
--- a/java/src/com/android/intentresolver/flags/Flags.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver.flags
-
-import com.android.systemui.flags.ReleasedFlag
-import com.android.systemui.flags.UnreleasedFlag
-
-// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to
-// make the flags available in the flag flipper app (see go/sysui-flags).
-// All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS.
-object Flags {
- private fun releasedFlag(name: String) = ReleasedFlag(name, "systemui")
-
- private fun unreleasedFlag(name: String, teamfood: Boolean = false) =
- UnreleasedFlag(name, "systemui", teamfood)
-}
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
index fadea934..f78fffd6 100644
--- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -32,12 +32,13 @@ import android.view.animation.DecelerateInterpolator;
import android.widget.Space;
import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.android.intentresolver.ChooserListAdapter;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolverListAdapter.ViewHolder;
-import com.android.internal.annotations.VisibleForTesting;
import com.google.android.collect.Lists;
@@ -47,7 +48,6 @@ import com.google.android.collect.Lists;
* row level by this adapter but not on the item level. Individual targets within the row are
* handled by {@link ChooserListAdapter}
*/
-@VisibleForTesting
public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
/**
@@ -65,15 +65,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
* out of `ChooserGridAdapter` altogether.
*/
public interface ChooserActivityDelegate {
- /** @return whether we're showing a tabbed (multi-profile) UI. */
- boolean shouldShowTabs();
-
- /**
- * @return a content preview {@link View} that's appropriate for the caller's share
- * content, constructed for display in the provided {@code parent} group.
- */
- View buildContentPreview(ViewGroup parent);
-
/** Notify the client that the item with the selected {@code itemIndex} was selected. */
void onTargetSelected(int itemIndex);
@@ -82,19 +73,10 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
* long-pressed.
*/
void onTargetLongPressed(int itemIndex);
-
- /**
- * Notify the client that the provided {@code View} should be configured as the new
- * "profile view" button. Callers should attach their own click listeners to implement
- * behaviors on this view.
- */
- void updateProfileViewButton(View newButtonFromProfileRow);
}
private static final int VIEW_TYPE_DIRECT_SHARE = 0;
private static final int VIEW_TYPE_NORMAL = 1;
- private static final int VIEW_TYPE_CONTENT_PREVIEW = 2;
- private static final int VIEW_TYPE_PROFILE = 3;
private static final int VIEW_TYPE_AZ_LABEL = 4;
private static final int VIEW_TYPE_CALLER_AND_RANK = 5;
private static final int VIEW_TYPE_FOOTER = 6;
@@ -105,8 +87,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
private final int mMaxTargetsPerRow;
private final boolean mShouldShowContentPreview;
- private final int mChooserWidthPixels;
private final int mChooserRowTextOptionTranslatePixelSize;
+ @Nullable
+ private RecyclerView mRecyclerView;
private int mChooserTargetWidth = 0;
@@ -130,7 +113,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
mShouldShowContentPreview = shouldShowContentPreview;
mMaxTargetsPerRow = maxTargetsPerRow;
- mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width);
mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize(
R.dimen.chooser_row_text_option_translate);
@@ -149,8 +131,23 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
});
}
+ @Override
+ public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+ mRecyclerView = recyclerView;
+ }
+
+ @Override
+ public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+ mRecyclerView = null;
+ }
+
public void setFooterHeight(int height) {
- mFooterHeight = height;
+ if (mFooterHeight != height) {
+ mFooterHeight = height;
+ // we always have at least one view, the footer, see getItemCount() and
+ // getFooterRowCount()
+ notifyItemChanged(getItemCount() - 1);
+ }
}
/**
@@ -164,11 +161,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return false;
}
- // Limit width to the maximum width of the chooser activity, if the maximum width is set
- if (mChooserWidthPixels >= 0) {
- width = Math.min(mChooserWidthPixels, width);
- }
-
int newWidth = width / mMaxTargetsPerRow;
if (newWidth != mChooserTargetWidth) {
mChooserTargetWidth = newWidth;
@@ -180,9 +172,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
public int getRowCount() {
return (int) (
- getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
+ getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount()
+ getAzLabelRowCount()
+ Math.ceil(
@@ -191,35 +181,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
);
}
- /**
- * Whether the "system" row of targets is displayed.
- * This area includes the content preview (if present) and action row.
- */
- public int getSystemRowCount() {
- // For the tabbed case we show the sticky content preview above the tabs,
- // please refer to shouldShowStickyContentPreview
- if (mChooserActivityDelegate.shouldShowTabs()) {
- return 0;
- }
-
- if (!mShouldShowContentPreview) {
- return 0;
- }
-
- if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) {
- return 0;
- }
-
- return 1;
- }
-
- public int getProfileRowCount() {
- if (mChooserActivityDelegate.shouldShowTabs()) {
- return 0;
- }
- return mChooserListAdapter.getOtherProfile() == null ? 0 : 1;
- }
-
public int getFooterRowCount() {
return 1;
}
@@ -250,38 +211,23 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return -1;
}
- return getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
+ return getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount();
}
@Override
public int getItemCount() {
- return getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
+ return getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount()
+ getAzLabelRowCount()
+ mChooserListAdapter.getAlphaTargetCount()
+ getFooterRowCount();
}
+ @NonNull
@Override
- public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
- case VIEW_TYPE_CONTENT_PREVIEW:
- return new ItemViewHolder(
- mChooserActivityDelegate.buildContentPreview(parent),
- viewType,
- null,
- null);
- case VIEW_TYPE_PROFILE:
- return new ItemViewHolder(
- createProfileView(parent),
- viewType,
- null,
- null);
case VIEW_TYPE_AZ_LABEL:
return new ItemViewHolder(
createAzLabelView(parent),
@@ -304,7 +250,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return new FooterViewHolder(sp, viewType);
default:
// Since we catch all possible viewTypes above, no chance this is being called.
- return null;
+ throw new IllegalStateException("unmatched view type");
}
}
@@ -318,6 +264,15 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
mAzLabelVisibility = isVisible;
int azRowPos = getAzLabelRowPosition();
if (azRowPos >= 0) {
+ if (mRecyclerView != null) {
+ for (int i = 0, size = mRecyclerView.getChildCount(); i < size; i++) {
+ View child = mRecyclerView.getChildAt(i);
+ if (mRecyclerView.getChildAdapterPosition(child) == azRowPos) {
+ child.setVisibility(isVisible ? View.VISIBLE : View.GONE);
+ }
+ }
+ return;
+ }
notifyItemChanged(azRowPos);
}
}
@@ -343,13 +298,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
@Override
public int getItemViewType(int position) {
- int count;
-
- int countSum = (count = getSystemRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW;
-
- countSum += (count = getProfileRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE;
+ int count = 0;
+ int countSum = count;
countSum += (count = getServiceTargetRowCount());
if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE;
@@ -369,12 +319,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return mChooserListAdapter.getPositionTargetType(getListPosition(position));
}
- private View createProfileView(ViewGroup parent) {
- View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false);
- mChooserActivityDelegate.updateProfileViewButton(profileRow);
- return profileRow;
- }
-
private View createAzLabelView(ViewGroup parent) {
return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false);
}
@@ -552,8 +496,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
}
int getListPosition(int position) {
- position -= getSystemRowCount() + getProfileRowCount();
-
final int serviceCount = mChooserListAdapter.getServiceTargetCount();
final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow);
if (position < serviceRows) {
diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt
new file mode 100644
index 00000000..dbea1e55
--- /dev/null
+++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.icon
+
+import android.content.ContentResolver
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.drawable.Icon
+import java.io.File
+import java.io.FileInputStream
+
+sealed interface ComposeIcon
+
+data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon
+
+data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon
+
+@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon
+
+fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? {
+ return when (type) {
+ Icon.TYPE_BITMAP -> BitmapIcon(bitmap)
+ Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) }
+ Icon.TYPE_DATA ->
+ BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength))
+ Icon.TYPE_URI -> uriIcon(resolver)
+ Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap))
+ Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) }
+ else -> error("unexpected icon type: $type")
+ }
+}
+
+fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? {
+ return when (type) {
+ Icon.TYPE_BITMAP -> BitmapIcon(bitmap)
+ Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) }
+ Icon.TYPE_DATA ->
+ BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength))
+ Icon.TYPE_URI -> uriIcon(resolver)
+ Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap))
+ Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) }
+ else -> error("unexpected icon type: $type")
+ }
+}
+
+// TODO: this is probably constant and doesn't need to be re-queried for each icon
+fun PackageManager.resourcesForPackage(pkgName: String): Resources? {
+ return if (pkgName == "android") {
+ Resources.getSystem()
+ } else {
+ runCatching {
+ this@resourcesForPackage.getApplicationInfo(
+ pkgName,
+ PackageManager.MATCH_UNINSTALLED_PACKAGES or
+ PackageManager.GET_SHARED_LIBRARY_FILES
+ )
+ }
+ .getOrNull()
+ ?.let { ai -> getResourcesForApplication(ai) }
+ }
+}
+
+private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? {
+ return runCatching {
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT,
+ ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri)
+ else -> FileInputStream(File(uriString))
+ }
+ }
+ .getOrNull()
+ ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) }
+}
diff --git a/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java
index 2eceb89c..f09fcfc5 100644
--- a/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java
+++ b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java
@@ -17,34 +17,31 @@
package com.android.intentresolver.icons;
import android.content.Context;
-import android.graphics.drawable.Drawable;
+import android.graphics.Bitmap;
import android.os.AsyncTask;
-import com.android.intentresolver.R;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.TargetPresentationGetter;
import java.util.function.Consumer;
-abstract class BaseLoadIconTask extends AsyncTask<Void, Void, Drawable> {
+abstract class BaseLoadIconTask extends AsyncTask<Void, Void, Bitmap> {
protected final Context mContext;
protected final TargetPresentationGetter.Factory mPresentationFactory;
- private final Consumer<Drawable> mCallback;
+ private final Consumer<Bitmap> mCallback;
BaseLoadIconTask(
Context context,
TargetPresentationGetter.Factory presentationFactory,
- Consumer<Drawable> callback) {
+ Consumer<Bitmap> callback) {
mContext = context;
mPresentationFactory = presentationFactory;
mCallback = callback;
}
- protected final Drawable loadIconPlaceholder() {
- return mContext.getDrawable(R.drawable.resolver_icon_placeholder);
- }
-
@Override
- protected final void onPostExecute(Drawable d) {
+ protected final void onPostExecute(@Nullable Bitmap d) {
mCallback.accept(d);
}
}
diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt
new file mode 100644
index 00000000..793b7621
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2024 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.intentresolver.icons
+
+import android.content.ComponentName
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.os.UserHandle
+import androidx.collection.LruCache
+import com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.chooser.SelectableTargetInfo
+import java.util.function.Consumer
+import javax.annotation.concurrent.GuardedBy
+import javax.inject.Qualifier
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching
+
+private typealias IconCache = LruCache<String, Bitmap>
+
+class CachingTargetDataLoader(
+ private val context: Context,
+ private val targetDataLoader: TargetDataLoader,
+ private val cacheSize: Int = 100,
+) : TargetDataLoader {
+ @GuardedBy("self") private val perProfileIconCache = HashMap<UserHandle, IconCache>()
+
+ override fun getOrLoadAppTargetIcon(
+ info: DisplayResolveInfo,
+ userHandle: UserHandle,
+ callback: Consumer<Drawable>,
+ ): Drawable? {
+ val cacheKey = info.toCacheKey()
+ return getCachedAppIcon(cacheKey, userHandle)?.toDrawable()
+ ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable ->
+ drawable.extractBitmap()?.let { getProfileIconCache(userHandle).put(cacheKey, it) }
+ callback.accept(drawable)
+ }
+ }
+
+ override fun getOrLoadDirectShareIcon(
+ info: SelectableTargetInfo,
+ userHandle: UserHandle,
+ callback: Consumer<Drawable>,
+ ): Drawable? {
+ val cacheKey = info.toCacheKey()
+ return cacheKey?.let { getCachedAppIcon(it, userHandle) }?.toDrawable()
+ ?: targetDataLoader.getOrLoadDirectShareIcon(info, userHandle) { drawable ->
+ if (cacheKey != null) {
+ drawable.extractBitmap()?.let {
+ getProfileIconCache(userHandle).put(cacheKey, it)
+ }
+ }
+ callback.accept(drawable)
+ }
+ }
+
+ override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) =
+ targetDataLoader.loadLabel(info, callback)
+
+ override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info)
+
+ private fun getCachedAppIcon(component: String, userHandle: UserHandle): Bitmap? =
+ getProfileIconCache(userHandle)[component]
+
+ private fun getProfileIconCache(userHandle: UserHandle): IconCache =
+ synchronized(perProfileIconCache) {
+ perProfileIconCache.getOrPut(userHandle) { IconCache(cacheSize) }
+ }
+
+ private fun DisplayResolveInfo.toCacheKey() =
+ ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name)
+ .flattenToString()
+
+ private fun SelectableTargetInfo.toCacheKey(): String? =
+ if (chooserTargetIcon != null) {
+ // do not cache icons for caller-provided targets
+ null
+ } else {
+ buildString {
+ append(chooserTargetComponentName?.flattenToString() ?: "")
+ append("|")
+ append(directShareShortcutInfo?.id ?: "")
+ }
+ }
+
+ private fun Bitmap.toDrawable(): Drawable {
+ return if (targetHoverAndKeyboardFocusStates()) {
+ HoverBitmapDrawable(this)
+ } else {
+ BitmapDrawable(context.resources, this)
+ }
+ }
+
+ private fun Drawable.extractBitmap(): Bitmap? {
+ return when (this) {
+ is BitmapDrawable -> bitmap
+ is HoverBitmapDrawable -> bitmap
+ else -> null
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
index 0e4d0209..1ff1ddfa 100644
--- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
+++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt
@@ -16,9 +16,9 @@
package com.android.intentresolver.icons
-import android.app.ActivityManager
import android.content.Context
-import android.content.pm.ResolveInfo
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.AsyncTask
import android.os.UserHandle
@@ -27,27 +27,34 @@ import androidx.annotation.GuardedBy
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
+import com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates
+import com.android.intentresolver.R
+import com.android.intentresolver.SimpleIconFactory
import com.android.intentresolver.TargetPresentationGetter
import com.android.intentresolver.chooser.DisplayResolveInfo
import com.android.intentresolver.chooser.SelectableTargetInfo
+import com.android.intentresolver.inject.ActivityOwned
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ActivityContext
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer
+import javax.inject.Provider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
/** An actual [TargetDataLoader] implementation. */
// TODO: replace async tasks with coroutines.
-class DefaultTargetDataLoader(
- private val context: Context,
- private val lifecycle: Lifecycle,
- private val isAudioCaptureDevice: Boolean,
-) : TargetDataLoader() {
- private val presentationFactory =
- TargetPresentationGetter.Factory(
- context,
- context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity
- ?: error("Unable to access ActivityManager")
- )
+class DefaultTargetDataLoader
+@AssistedInject
+constructor(
+ @ActivityContext private val context: Context,
+ @ActivityOwned private val lifecycle: Lifecycle,
+ private val iconFactoryProvider: Provider<SimpleIconFactory>,
+ private val presentationFactory: TargetPresentationGetter.Factory,
+ @Assisted private val isAudioCaptureDevice: Boolean,
+) : TargetDataLoader {
private val nextTaskId = AtomicInteger(0)
@GuardedBy("self") private val activeTasks = SparseArray<AsyncTask<*, *, *>>()
private val executor = Dispatchers.IO.asExecutor()
@@ -63,39 +70,42 @@ class DefaultTargetDataLoader(
)
}
- override fun loadAppTargetIcon(
+ override fun getOrLoadAppTargetIcon(
info: DisplayResolveInfo,
userHandle: UserHandle,
callback: Consumer<Drawable>,
- ) {
+ ): Drawable? {
val taskId = nextTaskId.getAndIncrement()
- LoadIconTask(context, info, userHandle, presentationFactory) { result ->
+ LoadIconTask(context, info, presentationFactory) { bitmap ->
removeTask(taskId)
- callback.accept(result)
+ callback.accept(bitmap?.toDrawable() ?: loadIconPlaceholder())
}
.also { addTask(taskId, it) }
.executeOnExecutor(executor)
+ return null
}
- override fun loadDirectShareIcon(
+ override fun getOrLoadDirectShareIcon(
info: SelectableTargetInfo,
userHandle: UserHandle,
callback: Consumer<Drawable>,
- ) {
+ ): Drawable? {
val taskId = nextTaskId.getAndIncrement()
LoadDirectShareIconTask(
context.createContextAsUser(userHandle, 0),
info,
presentationFactory,
- ) { result ->
+ iconFactoryProvider,
+ ) { bitmap ->
removeTask(taskId)
- callback.accept(result)
+ callback.accept(bitmap?.toDrawable() ?: loadIconPlaceholder())
}
.also { addTask(taskId, it) }
.executeOnExecutor(executor)
+ return null
}
- override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>) {
+ override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) {
val taskId = nextTaskId.getAndIncrement()
LoadLabelTask(context, info, isAudioCaptureDevice, presentationFactory) { result ->
removeTask(taskId)
@@ -105,8 +115,14 @@ class DefaultTargetDataLoader(
.executeOnExecutor(executor)
}
- override fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter =
- presentationFactory.makePresentationGetter(info)
+ override fun getOrLoadLabel(info: DisplayResolveInfo) {
+ if (!info.hasDisplayLabel()) {
+ val result =
+ LoadLabelTask.loadLabel(context, info, isAudioCaptureDevice, presentationFactory)
+ info.displayLabel = result.label
+ info.extendedInfo = result.subLabel
+ }
+ }
private fun addTask(id: Int, task: AsyncTask<*, *, *>) {
synchronized(activeTasks) { activeTasks.put(id, task) }
@@ -116,6 +132,9 @@ class DefaultTargetDataLoader(
synchronized(activeTasks) { activeTasks.remove(id) }
}
+ private fun loadIconPlaceholder(): Drawable =
+ requireNotNull(context.getDrawable(R.drawable.resolver_icon_placeholder))
+
private fun destroy() {
synchronized(activeTasks) {
for (i in 0 until activeTasks.size()) {
@@ -124,4 +143,17 @@ class DefaultTargetDataLoader(
activeTasks.clear()
}
}
+
+ private fun Bitmap.toDrawable(): Drawable {
+ return if (targetHoverAndKeyboardFocusStates()) {
+ HoverBitmapDrawable(this)
+ } else {
+ BitmapDrawable(context.resources, this)
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(isAudioCaptureDevice: Boolean): DefaultTargetDataLoader
+ }
}
diff --git a/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt b/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt
new file mode 100644
index 00000000..4a21df92
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.icons
+
+import android.graphics.Bitmap
+import com.android.launcher3.icons.FastBitmapDrawable
+
+/** A [FastBitmapDrawable] extension that provides access to the bitmap. */
+class HoverBitmapDrawable(val bitmap: Bitmap) : FastBitmapDrawable(bitmap) {
+
+ override fun newConstantState(): FastBitmapConstantState {
+ return HoverBitmapDrawableState(bitmap, iconColor)
+ }
+
+ private class HoverBitmapDrawableState(private val bitmap: Bitmap, color: Int) :
+ FastBitmapConstantState(bitmap, color) {
+ override fun createDrawable(): FastBitmapDrawable {
+ return HoverBitmapDrawable(bitmap)
+ }
+ }
+
+ companion object {
+ init {
+ setFlagHoverEnabled(true)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt
new file mode 100644
index 00000000..4b60d607
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt
@@ -0,0 +1,19 @@
+/*
+ * 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.intentresolver.icons
+
+data class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?)
diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java
index 6aee69b5..01f9330e 100644
--- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java
+++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java
@@ -16,7 +16,6 @@
package com.android.intentresolver.icons;
-import android.annotation.Nullable;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ActivityInfo;
@@ -24,12 +23,12 @@ import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Trace;
import android.util.Log;
+import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.android.intentresolver.SimpleIconFactory;
@@ -39,30 +38,36 @@ import com.android.intentresolver.util.UriFilters;
import java.util.function.Consumer;
+import javax.inject.Provider;
+
/**
* Loads direct share targets icons.
*/
class LoadDirectShareIconTask extends BaseLoadIconTask {
private static final String TAG = "DirectShareIconTask";
private final SelectableTargetInfo mTargetInfo;
+ private final Provider<SimpleIconFactory> mIconFactoryProvider;
LoadDirectShareIconTask(
Context context,
SelectableTargetInfo targetInfo,
TargetPresentationGetter.Factory presentationFactory,
- Consumer<Drawable> callback) {
+ Provider<SimpleIconFactory> iconFactoryProvider,
+ Consumer<Bitmap> callback) {
super(context, presentationFactory, callback);
+ mIconFactoryProvider = iconFactoryProvider;
mTargetInfo = targetInfo;
}
@Override
- protected Drawable doInBackground(Void... voids) {
- Drawable drawable;
+ @Nullable
+ protected Bitmap doInBackground(Void... voids) {
+ Bitmap iconBitmap = null;
Trace.beginSection("shortcut-icon");
try {
final Icon icon = mTargetInfo.getChooserTargetIcon();
if (icon == null || UriFilters.hasValidIcon(icon)) {
- drawable = getChooserTargetIconDrawable(
+ iconBitmap = getChooserTargetIconBitmap(
mContext,
icon,
mTargetInfo.getChooserTargetComponentName(),
@@ -70,7 +75,6 @@ class LoadDirectShareIconTask extends BaseLoadIconTask {
} else {
Log.e(TAG, "Failed to load shortcut icon for "
+ mTargetInfo.getChooserTargetComponentName() + "; no access");
- drawable = loadIconPlaceholder();
}
} catch (Exception e) {
Log.e(
@@ -78,15 +82,15 @@ class LoadDirectShareIconTask extends BaseLoadIconTask {
"Failed to load shortcut icon for "
+ mTargetInfo.getChooserTargetComponentName(),
e);
- drawable = loadIconPlaceholder();
} finally {
Trace.endSection();
}
- return drawable;
+ return iconBitmap;
}
@WorkerThread
- private Drawable getChooserTargetIconDrawable(
+ @Nullable
+ private Bitmap getChooserTargetIconBitmap(
Context context,
@Nullable Icon icon,
ComponentName targetComponentName,
@@ -122,10 +126,11 @@ class LoadDirectShareIconTask extends BaseLoadIconTask {
Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null);
// Raster target drawable with appIcon as a badge
- SimpleIconFactory sif = SimpleIconFactory.obtain(context);
- Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon);
- sif.recycle();
+ Bitmap directShareBadgedIcon;
+ try (SimpleIconFactory sif = mIconFactoryProvider.get()) {
+ directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon);
+ }
- return new BitmapDrawable(context.getResources(), directShareBadgedIcon);
+ return directShareBadgedIcon;
}
}
diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java
index 75132208..4573fadf 100644
--- a/java/src/com/android/intentresolver/icons/LoadIconTask.java
+++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java
@@ -19,11 +19,12 @@ package com.android.intentresolver.icons;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ResolveInfo;
-import android.graphics.drawable.Drawable;
+import android.graphics.Bitmap;
import android.os.Trace;
-import android.os.UserHandle;
import android.util.Log;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.TargetPresentationGetter;
import com.android.intentresolver.chooser.DisplayResolveInfo;
@@ -32,38 +33,36 @@ import java.util.function.Consumer;
class LoadIconTask extends BaseLoadIconTask {
private static final String TAG = "IconTask";
protected final DisplayResolveInfo mDisplayResolveInfo;
- private final UserHandle mUserHandle;
private final ResolveInfo mResolveInfo;
LoadIconTask(
Context context, DisplayResolveInfo dri,
- UserHandle userHandle,
TargetPresentationGetter.Factory presentationFactory,
- Consumer<Drawable> callback) {
+ Consumer<Bitmap> callback) {
super(context, presentationFactory, callback);
- mUserHandle = userHandle;
mDisplayResolveInfo = dri;
mResolveInfo = dri.getResolveInfo();
}
@Override
- protected Drawable doInBackground(Void... params) {
+ @Nullable
+ protected Bitmap doInBackground(Void... params) {
Trace.beginSection("app-icon");
try {
return loadIconForResolveInfo(mResolveInfo);
} catch (Exception e) {
ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName();
Log.e(TAG, "Failed to load app icon for " + componentName, e);
- return loadIconPlaceholder();
+ return null;
} finally {
Trace.endSection();
}
}
- protected final Drawable loadIconForResolveInfo(ResolveInfo ri) {
+ protected final Bitmap loadIconForResolveInfo(ResolveInfo ri) {
// Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons
// should be badged.
- return mPresentationFactory.makePresentationGetter(ri).getIcon(ri.userHandle);
+ return mPresentationFactory.makePresentationGetter(ri).getIconBitmap(ri.userHandle);
}
}
diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java
index a0867b8e..6d443f78 100644
--- a/java/src/com/android/intentresolver/icons/LoadLabelTask.java
+++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java
@@ -28,16 +28,16 @@ import com.android.intentresolver.chooser.DisplayResolveInfo;
import java.util.function.Consumer;
-class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> {
+class LoadLabelTask extends AsyncTask<Void, Void, LabelInfo> {
private final Context mContext;
private final DisplayResolveInfo mDisplayResolveInfo;
private final boolean mIsAudioCaptureDevice;
protected final TargetPresentationGetter.Factory mPresentationFactory;
- private final Consumer<CharSequence[]> mCallback;
+ private final Consumer<LabelInfo> mCallback;
LoadLabelTask(Context context, DisplayResolveInfo dri,
boolean isAudioCaptureDevice, TargetPresentationGetter.Factory presentationFactory,
- Consumer<CharSequence[]> callback) {
+ Consumer<LabelInfo> callback) {
mContext = context;
mDisplayResolveInfo = dri;
mIsAudioCaptureDevice = isAudioCaptureDevice;
@@ -46,49 +46,52 @@ class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> {
}
@Override
- protected CharSequence[] doInBackground(Void... voids) {
+ protected LabelInfo doInBackground(Void... voids) {
try {
Trace.beginSection("app-label");
- return loadLabel();
+ return loadLabel(
+ mContext, mDisplayResolveInfo, mIsAudioCaptureDevice, mPresentationFactory);
} finally {
Trace.endSection();
}
}
- private CharSequence[] loadLabel() {
- TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter(
- mDisplayResolveInfo.getResolveInfo());
+ static LabelInfo loadLabel(
+ Context context,
+ DisplayResolveInfo displayResolveInfo,
+ boolean isAudioCaptureDevice,
+ TargetPresentationGetter.Factory presentationFactory) {
+ TargetPresentationGetter pg = presentationFactory.makePresentationGetter(
+ displayResolveInfo.getResolveInfo());
- if (mIsAudioCaptureDevice) {
+ if (isAudioCaptureDevice) {
// This is an audio capture device, so check record permissions
- ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo;
+ ActivityInfo activityInfo = displayResolveInfo.getResolveInfo().activityInfo;
String packageName = activityInfo.packageName;
int uid = activityInfo.applicationInfo.uid;
boolean hasRecordPermission =
PermissionChecker.checkPermissionForPreflight(
- mContext,
+ context,
android.Manifest.permission.RECORD_AUDIO, -1, uid,
packageName)
== android.content.pm.PackageManager.PERMISSION_GRANTED;
if (!hasRecordPermission) {
// Doesn't have record permission, so warn the user
- return new CharSequence[]{
+ return new LabelInfo(
pg.getLabel(),
- mContext.getString(R.string.usb_device_resolve_prompt_warn)
- };
+ context.getString(R.string.usb_device_resolve_prompt_warn));
}
}
- return new CharSequence[]{
+ return new LabelInfo(
pg.getLabel(),
- pg.getSubLabel()
- };
+ pg.getSubLabel());
}
@Override
- protected void onPostExecute(CharSequence[] result) {
+ protected void onPostExecute(LabelInfo result) {
mCallback.accept(result);
}
}
diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt
index 50f731f8..7cbd040e 100644
--- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt
+++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt
@@ -16,35 +16,31 @@
package com.android.intentresolver.icons
-import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.UserHandle
-import com.android.intentresolver.TargetPresentationGetter
import com.android.intentresolver.chooser.DisplayResolveInfo
import com.android.intentresolver.chooser.SelectableTargetInfo
import java.util.function.Consumer
/** A target data loader contract. Added to support testing. */
-abstract class TargetDataLoader {
+interface TargetDataLoader {
/** Load an app target icon */
- abstract fun loadAppTargetIcon(
+ fun getOrLoadAppTargetIcon(
info: DisplayResolveInfo,
userHandle: UserHandle,
callback: Consumer<Drawable>,
- )
+ ): Drawable?
/** Load a shortcut icon */
- abstract fun loadDirectShareIcon(
+ fun getOrLoadDirectShareIcon(
info: SelectableTargetInfo,
userHandle: UserHandle,
callback: Consumer<Drawable>,
- )
+ ): Drawable?
/** Load target label */
- abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>)
+ fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>)
- /** Create a presentation getter to be used with a [DisplayResolveInfo] */
- // TODO: get rid of DisplayResolveInfo's dependency on the presentation getter and remove this
- // method.
- abstract fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter
+ /** Loads DisplayResolveInfo's display label synchronously, if needed */
+ fun getOrLoadLabel(info: DisplayResolveInfo)
}
diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt
new file mode 100644
index 00000000..d6d4aae1
--- /dev/null
+++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt
@@ -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.intentresolver.icons
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.PackageManager
+import com.android.intentresolver.SimpleIconFactory
+import com.android.intentresolver.TargetPresentationGetter
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityComponent
+import dagger.hilt.android.qualifiers.ActivityContext
+import dagger.hilt.android.scopes.ActivityScoped
+import javax.inject.Provider
+
+@Module
+@InstallIn(ActivityComponent::class)
+object TargetDataLoaderModule {
+ @Provides
+ fun simpleIconFactory(@ActivityContext context: Context): SimpleIconFactory =
+ SimpleIconFactory.obtain(context)
+
+ @Provides
+ fun presentationGetterFactory(
+ iconFactoryProvider: Provider<SimpleIconFactory>,
+ packageManager: PackageManager,
+ activityManager: ActivityManager,
+ ): TargetPresentationGetter.Factory =
+ TargetPresentationGetter.Factory(
+ iconFactoryProvider,
+ packageManager,
+ activityManager.launcherLargeIconDensity,
+ )
+
+ @Provides
+ @ActivityScoped
+ @Caching
+ fun cachingTargetDataLoader(
+ @ActivityContext context: Context,
+ dataLoaderFactory: DefaultTargetDataLoader.Factory,
+ ): TargetDataLoader =
+ // Intended to be used in Chooser only thus the hardcoded isAudioCaptureDevice value.
+ CachingTargetDataLoader(context, dataLoaderFactory.create(isAudioCaptureDevice = false))
+}
diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt
new file mode 100644
index 00000000..60eff925
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.inject
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import androidx.lifecycle.SavedStateHandle
+import com.android.intentresolver.Flags.saveShareouselState
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.ActivityModelRepository
+import com.android.intentresolver.ui.viewmodel.CHOOSER_REQUEST_KEY
+import com.android.intentresolver.ui.viewmodel.readChooserRequest
+import com.android.intentresolver.util.ownedByCurrentUser
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Qualifier
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object ActivityModelModule {
+ @Provides
+ @ChooserIntent
+ fun chooserIntent(activityModelRepo: ActivityModelRepository): Intent =
+ activityModelRepo.value.intent
+
+ @Provides
+ @ViewModelScoped
+ fun provideInitialRequest(
+ activityModelRepo: ActivityModelRepository,
+ savedStateHandle: SavedStateHandle,
+ ): ValidationResult<ChooserRequest> {
+ val activityModel = activityModelRepo.value
+ val extras = restoreChooserRequestExtras(activityModel.intent.extras, savedStateHandle)
+ return readChooserRequest(activityModel, extras)
+ }
+
+ @Provides
+ fun provideChooserRequest(initialRequest: ValidationResult<ChooserRequest>): ChooserRequest =
+ requireNotNull((initialRequest as? Valid)?.value) {
+ "initialRequest is Invalid, no chooser request available"
+ }
+
+ @Provides
+ @TargetIntent
+ fun targetIntent(chooserReq: ValidationResult<ChooserRequest>): Intent =
+ requireNotNull((chooserReq as? Valid)?.value?.targetIntent) { "no target intent available" }
+
+ @Provides
+ fun customActions(chooserReq: ValidationResult<ChooserRequest>): List<ChooserAction> =
+ requireNotNull((chooserReq as? Valid)?.value?.chooserActions) {
+ "no chooser actions available"
+ }
+
+ @Provides
+ @ViewModelScoped
+ @ContentUris
+ fun selectedUris(chooserRequest: ValidationResult<ChooserRequest>): List<Uri> =
+ requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) {
+ "no selected uris available"
+ }
+
+ @Provides
+ @FocusedItemIndex
+ fun focusedItemIndex(chooserReq: ValidationResult<ChooserRequest>): Int =
+ requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) {
+ "no focused item position available"
+ }
+
+ @Provides
+ @AdditionalContent
+ fun additionalContentUri(chooserReq: ValidationResult<ChooserRequest>): Uri =
+ requireNotNull((chooserReq as? Valid)?.value?.additionalContentUri) {
+ "no additional content uri available"
+ }
+}
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class FocusedItemIndex
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AdditionalContent
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ChooserIntent
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ContentUris
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent
+
+private val Intent.contentUris: Sequence<Uri>
+ get() = sequence {
+ if (Intent.ACTION_SEND == action) {
+ getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ ?.takeIf { it.ownedByCurrentUser }
+ ?.let { yield(it) }
+ } else {
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
+ if (uri.ownedByCurrentUser) {
+ yield(uri)
+ }
+ }
+ }
+ }
+
+private fun restoreChooserRequestExtras(
+ initialExtras: Bundle?,
+ savedStateHandle: SavedStateHandle,
+): Bundle =
+ if (saveShareouselState()) {
+ savedStateHandle.get<Bundle>(CHOOSER_REQUEST_KEY)?.let { savedSateBundle ->
+ Bundle().apply {
+ initialExtras?.let { putAll(it) }
+ putAll(savedSateBundle)
+ }
+ } ?: initialExtras
+ } else {
+ initialExtras
+ } ?: Bundle()
diff --git a/java/src/com/android/intentresolver/inject/ActivityModule.kt b/java/src/com/android/intentresolver/inject/ActivityModule.kt
new file mode 100644
index 00000000..21bfe4c6
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/ActivityModule.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.intentresolver.inject
+
+import android.app.Activity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityComponent
+import kotlinx.coroutines.CoroutineScope
+
+@Module
+@InstallIn(ActivityComponent::class)
+object ActivityModule {
+
+ @Provides
+ @ActivityOwned
+ fun lifecycle(activity: Activity): Lifecycle {
+ check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" }
+ return activity.lifecycle
+ }
+
+ @Provides
+ @ActivityOwned
+ fun activityScope(activity: Activity): CoroutineScope {
+ check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" }
+ return activity.lifecycleScope
+ }
+}
diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt
new file mode 100644
index 00000000..5fbdf090
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.intentresolver.inject
+
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Looper
+import android.os.Process
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+
+// thread
+private const val BROADCAST_SLOW_DISPATCH_THRESHOLD = 1000L
+private const val BROADCAST_SLOW_DELIVERY_THRESHOLD = 1000L
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ConcurrencyModule {
+
+ @Provides @Main fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
+
+ /** Injectable alternative to [MainScope()][kotlinx.coroutines.MainScope] */
+ @Provides
+ @Singleton
+ @Main
+ fun mainCoroutineScope(@Main mainDispatcher: CoroutineDispatcher) =
+ CoroutineScope(SupervisorJob() + mainDispatcher)
+
+ @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Provides
+ @Singleton
+ @Broadcast
+ fun provideBroadcastLooper(): Looper {
+ val thread = HandlerThread("BroadcastReceiver", Process.THREAD_PRIORITY_BACKGROUND)
+ thread.start()
+ thread.looper.setSlowLogThresholdMs(
+ BROADCAST_SLOW_DISPATCH_THRESHOLD,
+ BROADCAST_SLOW_DELIVERY_THRESHOLD
+ )
+ return thread.looper
+ }
+
+ /** Provide a BroadcastReceiver Executor (for sending and receiving broadcasts). */
+ @Provides
+ @Singleton
+ @Broadcast
+ fun provideBroadcastHandler(@Broadcast looper: Looper): Handler {
+ return Handler(looper)
+ }
+}
diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt
new file mode 100644
index 00000000..77315cac
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.intentresolver.inject
+
+import javax.inject.Qualifier
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ViewModelOwned
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ApplicationOwned
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ApplicationUser
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Broadcast
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main
diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt
new file mode 100644
index 00000000..af054625
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.inject
+
+import android.content.Context
+import com.android.intentresolver.logging.EventLogImpl
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+object SingletonModule {
+ @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence()
+
+ @Provides
+ @Reusable
+ @ApplicationOwned
+ fun resources(@ApplicationContext context: Context) = context.resources
+}
diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt
new file mode 100644
index 00000000..2a123dc7
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/SystemServices.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.inject
+
+import android.app.ActivityManager
+import android.app.admin.DevicePolicyManager
+import android.app.prediction.AppPredictionManager
+import android.content.ClipboardManager
+import android.content.ContentInterface
+import android.content.ContentResolver
+import android.content.Context
+import android.content.pm.LauncherApps
+import android.content.pm.ShortcutManager
+import android.os.UserManager
+import android.view.WindowManager
+import androidx.core.content.getSystemService
+import com.android.intentresolver.data.repository.UserScopedService
+import com.android.intentresolver.data.repository.UserScopedServiceImpl
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+inline fun <reified T> Context.requireSystemService(): T {
+ return checkNotNull(getSystemService())
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ActivityManagerModule {
+ @Provides
+ fun activityManager(@ApplicationContext ctx: Context): ActivityManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ClipboardManagerModule {
+ @Provides
+ fun clipboardManager(@ApplicationContext ctx: Context): ClipboardManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface ContentResolverModule {
+ @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface
+
+ companion object {
+ @Provides
+ fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class DevicePolicyManagerModule {
+ @Provides
+ fun devicePolicyManager(@ApplicationContext ctx: Context): DevicePolicyManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class LauncherAppsModule {
+ @Provides
+ fun launcherApps(@ApplicationContext ctx: Context): LauncherApps = ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class PackageManagerModule {
+ @Provides
+ fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class PredictionManagerModule {
+ @Provides
+ fun scopedPredictionManager(
+ @ApplicationContext ctx: Context,
+ ): UserScopedService<AppPredictionManager> {
+ return UserScopedServiceImpl(ctx, AppPredictionManager::class)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ShortcutManagerModule {
+ @Provides
+ fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager {
+ return ctx.requireSystemService()
+ }
+
+ @Provides
+ fun scopedShortcutManager(
+ @ApplicationContext ctx: Context,
+ ): UserScopedService<ShortcutManager> {
+ return UserScopedServiceImpl(ctx, ShortcutManager::class)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class UserManagerModule {
+ @Provides
+ fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService()
+
+ @Provides
+ fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> {
+ return UserScopedServiceImpl(ctx, UserManager::class)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class WindowManagerModule {
+ @Provides
+ fun windowManager(@ApplicationContext ctx: Context): WindowManager = ctx.requireSystemService()
+}
diff --git a/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt
new file mode 100644
index 00000000..4dda2653
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.inject
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.ViewModelLifecycle
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.scopes.ViewModelScoped
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object ViewModelCoroutineScopeModule {
+ @Provides
+ @ViewModelScoped
+ @ViewModelOwned
+ fun viewModelScope(@Main dispatcher: CoroutineDispatcher, lifecycle: ViewModelLifecycle) =
+ lifecycle.asCoroutineScope(dispatcher)
+}
+
+fun ViewModelLifecycle.asCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) =
+ CoroutineScope(context).also { addOnClearedListener { it.cancel() } }
diff --git a/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt b/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt
new file mode 100644
index 00000000..f8894de5
--- /dev/null
+++ b/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.interactive.data.repository
+
+import android.os.Bundle
+import androidx.lifecycle.SavedStateHandle
+import com.android.intentresolver.IChooserController
+import com.android.intentresolver.interactive.domain.model.ChooserIntentUpdater
+import dagger.hilt.android.scopes.ViewModelScoped
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+
+private const val INTERACTIVE_SESSION_CALLBACK_KEY = "interactive-session-callback"
+
+@ViewModelScoped
+class InteractiveSessionCallbackRepository @Inject constructor(savedStateHandle: SavedStateHandle) {
+ private val intentUpdaterRef =
+ AtomicReference<ChooserIntentUpdater?>(
+ savedStateHandle
+ .get<Bundle>(INTERACTIVE_SESSION_CALLBACK_KEY)
+ ?.let { it.getBinder(INTERACTIVE_SESSION_CALLBACK_KEY) }
+ ?.let { binder ->
+ binder.queryLocalInterface(IChooserController.DESCRIPTOR)
+ as? ChooserIntentUpdater
+ }
+ )
+
+ val intentUpdater: ChooserIntentUpdater?
+ get() = intentUpdaterRef.get()
+
+ init {
+ savedStateHandle.setSavedStateProvider(INTERACTIVE_SESSION_CALLBACK_KEY) {
+ Bundle().apply { putBinder(INTERACTIVE_SESSION_CALLBACK_KEY, intentUpdater) }
+ }
+ }
+
+ fun setChooserIntentUpdater(intentUpdater: ChooserIntentUpdater) {
+ intentUpdaterRef.compareAndSet(null, intentUpdater)
+ }
+}
diff --git a/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt b/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt
new file mode 100644
index 00000000..09b79985
--- /dev/null
+++ b/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.interactive.domain.interactor
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.IBinder
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.ActivityModelRepository
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import com.android.intentresolver.interactive.data.repository.InteractiveSessionCallbackRepository
+import com.android.intentresolver.interactive.domain.model.ChooserIntentUpdater
+import com.android.intentresolver.ui.viewmodel.readChooserRequest
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.log
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Inject
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TAG = "ChooserSession"
+
+@ViewModelScoped
+class InteractiveSessionInteractor
+@Inject
+constructor(
+ activityModelRepo: ActivityModelRepository,
+ private val chooserRequestRepository: ChooserRequestRepository,
+ private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository,
+ private val interactiveCallbackRepo: InteractiveSessionCallbackRepository,
+) {
+ private val activityModel = activityModelRepo.value
+ private val sessionCallback =
+ chooserRequestRepository.initialRequest.interactiveSessionCallback?.let {
+ SafeChooserInteractiveSessionCallback(it)
+ }
+ val isSessionActive = MutableStateFlow(true)
+
+ suspend fun activate() = coroutineScope {
+ if (sessionCallback == null || activityModel.isTaskRoot) {
+ sessionCallback?.registerChooserController(null)
+ return@coroutineScope
+ }
+ launch {
+ val callbackBinder: IBinder = sessionCallback.asBinder()
+ if (callbackBinder.isBinderAlive) {
+ val deathRecipient = IBinder.DeathRecipient { isSessionActive.value = false }
+ callbackBinder.linkToDeath(deathRecipient, 0)
+ try {
+ awaitCancellation()
+ } finally {
+ runCatching { sessionCallback.asBinder().unlinkToDeath(deathRecipient, 0) }
+ }
+ } else {
+ isSessionActive.value = false
+ }
+ }
+ val chooserIntentUpdater =
+ interactiveCallbackRepo.intentUpdater
+ ?: ChooserIntentUpdater().also {
+ interactiveCallbackRepo.setChooserIntentUpdater(it)
+ sessionCallback.registerChooserController(it)
+ }
+ chooserIntentUpdater.chooserIntent.collect { onIntentUpdated(it) }
+ }
+
+ fun sendTopDrawerTopOffsetChange(offset: Int) {
+ sessionCallback?.onDrawerVerticalOffsetChanged(offset)
+ }
+
+ fun endSession() {
+ sessionCallback?.registerChooserController(null)
+ }
+
+ private fun onIntentUpdated(chooserIntent: Intent?) {
+ if (chooserIntent == null) {
+ isSessionActive.value = false
+ return
+ }
+
+ val result =
+ readChooserRequest(
+ chooserIntent.extras ?: Bundle(),
+ activityModel.launchedFromPackage,
+ activityModel.referrer,
+ )
+ when (result) {
+ is Valid<ChooserRequest> -> {
+ val newRequest = result.value
+ pendingSelectionCallbackRepo.pendingTargetIntent.compareAndSet(
+ null,
+ result.value.targetIntent,
+ )
+ chooserRequestRepository.chooserRequest.update {
+ it.copy(
+ targetIntent = newRequest.targetIntent,
+ targetAction = newRequest.targetAction,
+ isSendActionTarget = newRequest.isSendActionTarget,
+ targetType = newRequest.targetType,
+ filteredComponentNames = newRequest.filteredComponentNames,
+ callerChooserTargets = newRequest.callerChooserTargets,
+ additionalTargets = newRequest.additionalTargets,
+ replacementExtras = newRequest.replacementExtras,
+ initialIntents = newRequest.initialIntents,
+ shareTargetFilter = newRequest.shareTargetFilter,
+ chosenComponentSender = newRequest.chosenComponentSender,
+ refinementIntentSender = newRequest.refinementIntentSender,
+ )
+ }
+ pendingSelectionCallbackRepo.pendingTargetIntent.compareAndSet(
+ result.value.targetIntent,
+ null,
+ )
+ }
+ is Invalid -> {
+ result.errors.forEach { it.log(TAG) }
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt b/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt
new file mode 100644
index 00000000..d746a3b5
--- /dev/null
+++ b/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.interactive.domain.interactor
+
+import android.util.Log
+import com.android.intentresolver.IChooserController
+import com.android.intentresolver.IChooserInteractiveSessionCallback
+
+private const val TAG = "SessionCallback"
+
+class SafeChooserInteractiveSessionCallback(
+ private val delegate: IChooserInteractiveSessionCallback
+) : IChooserInteractiveSessionCallback by delegate {
+
+ override fun registerChooserController(updater: IChooserController?) {
+ if (!isAlive) return
+ runCatching { delegate.registerChooserController(updater) }
+ .onFailure { Log.e(TAG, "Failed to invoke registerChooserController", it) }
+ }
+
+ override fun onDrawerVerticalOffsetChanged(offset: Int) {
+ if (!isAlive) return
+ runCatching { delegate.onDrawerVerticalOffsetChanged(offset) }
+ .onFailure { Log.e(TAG, "Failed to invoke onDrawerVerticalOffsetChanged", it) }
+ }
+
+ private val isAlive: Boolean
+ get() = delegate.asBinder().isBinderAlive
+}
diff --git a/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt b/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt
new file mode 100644
index 00000000..5466a95d
--- /dev/null
+++ b/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.interactive.domain.model
+
+import android.content.Intent
+import com.android.intentresolver.IChooserController
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+
+private val NotSet = Intent()
+
+class ChooserIntentUpdater : IChooserController.Stub() {
+ private val updates = MutableStateFlow<Intent?>(NotSet)
+
+ val chooserIntent: Flow<Intent?>
+ get() = updates.filter { it !== NotSet }
+
+ override fun updateIntent(chooserIntent: Intent?) {
+ updates.value = chooserIntent
+ }
+}
diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt
new file mode 100644
index 00000000..b92f0732
--- /dev/null
+++ b/java/src/com/android/intentresolver/logging/EventLog.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.intentresolver.logging
+
+import android.net.Uri
+import android.util.HashedStringCache
+
+/** Logs notable events during ShareSheet usage. */
+interface EventLog {
+
+ companion object {
+ const val SELECTION_TYPE_SERVICE = 1
+ const val SELECTION_TYPE_APP = 2
+ const val SELECTION_TYPE_STANDARD = 3
+ const val SELECTION_TYPE_COPY = 4
+ const val SELECTION_TYPE_NEARBY = 5
+ const val SELECTION_TYPE_EDIT = 6
+ const val SELECTION_TYPE_MODIFY_SHARE = 7
+ const val SELECTION_TYPE_CUSTOM_ACTION = 8
+ }
+
+ fun logChooserActivityShown(isWorkProfile: Boolean, targetMimeType: String?, systemCost: Long)
+
+ fun logShareStarted(
+ packageName: String?,
+ mimeType: String?,
+ appProvidedDirect: Int,
+ appProvidedApp: Int,
+ isWorkprofile: Boolean,
+ previewType: Int,
+ intent: String?,
+ customActionCount: Int,
+ modifyShareActionProvided: Boolean
+ )
+
+ fun logCustomActionSelected(positionPicked: Int)
+
+ fun logShareTargetSelected(
+ targetType: Int,
+ packageName: String?,
+ positionPicked: Int,
+ directTargetAlsoRanked: Int,
+ numCallerProvided: Int,
+ directTargetHashed: HashedStringCache.HashResult?,
+ isPinned: Boolean,
+ successfullySelected: Boolean,
+ selectionCost: Long
+ )
+
+ fun logDirectShareTargetReceived(category: Int, latency: Int)
+
+ fun logActionShareWithPreview(previewType: Int)
+
+ fun logActionSelected(targetType: Int)
+
+ fun logContentPreviewWarning(uri: Uri?)
+
+ fun logSharesheetTriggered()
+
+ fun logSharesheetAppLoadComplete()
+
+ fun logSharesheetDirectLoadComplete()
+
+ fun logSharesheetDirectLoadTimeout()
+
+ fun logSharesheetProfileChanged()
+
+ fun logSharesheetExpansionChanged(isCollapsed: Boolean)
+
+ fun logSharesheetAppShareRankingTimeout()
+
+ fun logSharesheetEmptyDirectShareRow()
+
+ /** Log payload selection */
+ fun logPayloadSelectionChanged()
+}
diff --git a/java/src/com/android/intentresolver/logging/EventLog.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java
index b30e825b..8e9543bc 100644
--- a/java/src/com/android/intentresolver/logging/EventLog.java
+++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java
@@ -16,7 +16,6 @@
package com.android.intentresolver.logging;
-import android.annotation.Nullable;
import android.content.Intent;
import android.metrics.LogMaker;
import android.net.Uri;
@@ -24,6 +23,8 @@ import android.provider.MediaStore;
import android.util.HashedStringCache;
import android.util.Log;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.ChooserActivity;
import com.android.intentresolver.contentpreview.ContentPreviewType;
import com.android.internal.annotations.VisibleForTesting;
@@ -32,84 +33,42 @@ import com.android.internal.logging.InstanceIdSequence;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.UiEvent;
import com.android.internal.logging.UiEventLogger;
-import com.android.internal.logging.UiEventLoggerImpl;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.FrameworkStatsLog;
+import javax.inject.Inject;
+
/**
* Helper for writing Sharesheet atoms to statsd log.
- * @hide
*/
-public class EventLog {
+public class EventLogImpl implements EventLog {
private static final String TAG = "ChooserActivity";
private static final boolean DEBUG = true;
- public static final int SELECTION_TYPE_SERVICE = 1;
- public static final int SELECTION_TYPE_APP = 2;
- public static final int SELECTION_TYPE_STANDARD = 3;
- public static final int SELECTION_TYPE_COPY = 4;
- public static final int SELECTION_TYPE_NEARBY = 5;
- public static final int SELECTION_TYPE_EDIT = 6;
- public static final int SELECTION_TYPE_MODIFY_SHARE = 7;
- public static final int SELECTION_TYPE_CUSTOM_ACTION = 8;
-
- /**
- * This shim is provided only for testing. In production, clients will only ever use a
- * {@link DefaultFrameworkStatsLogger}.
- */
- @VisibleForTesting
- interface FrameworkStatsLogger {
- /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */
- void write(
- int frameworkEventId,
- int appEventId,
- String packageName,
- int instanceId,
- String mimeType,
- int numAppProvidedDirectTargets,
- int numAppProvidedAppTargets,
- boolean isWorkProfile,
- int previewType,
- int intentType,
- int numCustomActions,
- boolean modifyShareActionProvided);
-
- /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */
- void write(
- int frameworkEventId,
- int appEventId,
- String packageName,
- int instanceId,
- int positionPicked,
- boolean isPinned);
- }
-
private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13);
- // A small per-notification ID, used for statsd logging.
- // TODO: consider precomputing and storing as final.
- private static InstanceIdSequence sInstanceIdSequence;
- private InstanceId mInstanceId;
+ private final InstanceId mInstanceId;
private final UiEventLogger mUiEventLogger;
private final FrameworkStatsLogger mFrameworkStatsLogger;
private final MetricsLogger mMetricsLogger;
- public EventLog() {
- this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger());
+ public static InstanceIdSequence newIdSequence() {
+ return new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX);
}
- @VisibleForTesting
- EventLog(
- UiEventLogger uiEventLogger,
- FrameworkStatsLogger frameworkLogger,
- MetricsLogger metricsLogger) {
+ @Inject
+ public EventLogImpl(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger,
+ MetricsLogger metricsLogger, InstanceId instanceId) {
mUiEventLogger = uiEventLogger;
mFrameworkStatsLogger = frameworkLogger;
mMetricsLogger = metricsLogger;
+ mInstanceId = instanceId;
}
+
/** Records metrics for the start time of the {@link ChooserActivity}. */
+ @Override
public void logChooserActivityShown(
boolean isWorkProfile, String targetMimeType, long systemCost) {
mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)
@@ -120,6 +79,7 @@ public class EventLog {
}
/** Logs a UiEventReported event for the system sharesheet completing initial start-up. */
+ @Override
public void logShareStarted(
String packageName,
String mimeType,
@@ -133,7 +93,7 @@ public class EventLog {
mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED,
/* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(),
/* package_name = 2 */ packageName,
- /* instance_id = 3 */ getInstanceId().getId(),
+ /* instance_id = 3 */ mInstanceId.getId(),
/* mime_type = 4 */ mimeType,
/* num_app_provided_direct_targets = 5 */ appProvidedDirect,
/* num_app_provided_app_targets = 6 */ appProvidedApp,
@@ -149,12 +109,13 @@ public class EventLog {
*
* @param positionPicked index of the custom action within the list of custom actions.
*/
+ @Override
public void logCustomActionSelected(int positionPicked) {
mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
/* event_id = 1 */
SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(),
/* package_name = 2 */ null,
- /* instance_id = 3 */ getInstanceId().getId(),
+ /* instance_id = 3 */ mInstanceId.getId(),
/* position_picked = 4 */ positionPicked,
/* is_pinned = 5 */ false);
}
@@ -164,6 +125,7 @@ public class EventLog {
* TODO: document parameters and/or consider breaking up by targetType so we don't have to
* support an overly-generic signature.
*/
+ @Override
public void logShareTargetSelected(
int targetType,
String packageName,
@@ -177,7 +139,7 @@ public class EventLog {
mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
/* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(),
/* package_name = 2 */ packageName,
- /* instance_id = 3 */ getInstanceId().getId(),
+ /* instance_id = 3 */ mInstanceId.getId(),
/* position_picked = 4 */ positionPicked,
/* is_pinned = 5 */ isPinned);
@@ -209,6 +171,7 @@ public class EventLog {
}
/** Log when direct share targets were received. */
+ @Override
public void logDirectShareTargetReceived(int category, int latency) {
mMetricsLogger.write(new LogMaker(category).setSubtype(latency));
}
@@ -217,12 +180,14 @@ public class EventLog {
* Log when we display a preview UI of the specified {@code previewType} as part of our
* Sharesheet session.
*/
+ @Override
public void logActionShareWithPreview(int previewType) {
mMetricsLogger.write(
new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType));
}
/** Log when the user selects an action button with the specified {@code targetType}. */
+ @Override
public void logActionSelected(int targetType) {
if (targetType == SELECTION_TYPE_COPY) {
LogMaker targetLogMaker = new LogMaker(
@@ -232,12 +197,13 @@ public class EventLog {
mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
/* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(),
/* package_name = 2 */ "",
- /* instance_id = 3 */ getInstanceId().getId(),
+ /* instance_id = 3 */ mInstanceId.getId(),
/* position_picked = 4 */ -1,
/* is_pinned = 5 */ false);
}
/** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */
+ @Override
public void logContentPreviewWarning(Uri uri) {
// The ContentResolver already logs the exception. Log something more informative.
Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
@@ -248,55 +214,68 @@ public class EventLog {
}
/** Logs a UiEventReported event for the system sharesheet being triggered by the user. */
+ @Override
public void logSharesheetTriggered() {
- log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, mInstanceId);
}
/** Logs a UiEventReported event for the system sharesheet completing loading app targets. */
+ @Override
public void logSharesheetAppLoadComplete() {
- log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, mInstanceId);
}
/**
* Logs a UiEventReported event for the system sharesheet completing loading service targets.
*/
+ @Override
public void logSharesheetDirectLoadComplete() {
- log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, mInstanceId);
}
/**
* Logs a UiEventReported event for the system sharesheet timing out loading service targets.
*/
+ @Override
public void logSharesheetDirectLoadTimeout() {
- log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, mInstanceId);
}
/**
* Logs a UiEventReported event for the system sharesheet switching
* between work and main profile.
*/
+ @Override
public void logSharesheetProfileChanged() {
- log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, mInstanceId);
}
/** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */
+ @Override
public void logSharesheetExpansionChanged(boolean isCollapsed) {
log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED :
- SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId());
+ SharesheetStandardEvent.SHARESHEET_EXPANDED, mInstanceId);
}
/**
* Logs a UiEventReported event for the system sharesheet app share ranking timing out.
*/
+ @Override
public void logSharesheetAppShareRankingTimeout() {
- log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, mInstanceId);
}
/**
* Logs a UiEventReported event for the system sharesheet when direct share row is empty.
*/
+ @Override
public void logSharesheetEmptyDirectShareRow() {
- log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId());
+ log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId);
+ }
+
+ @Override
+ public void logPayloadSelectionChanged() {
+ log(SharesheetStandardEvent.SHARESHEET_PAYLOAD_TOGGLED, mInstanceId);
}
/**
@@ -313,19 +292,6 @@ public class EventLog {
}
/**
- * @return A unique {@link InstanceId} to join across events recorded by this logger instance.
- */
- private InstanceId getInstanceId() {
- if (mInstanceId == null) {
- if (sInstanceIdSequence == null) {
- sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX);
- }
- mInstanceId = sInstanceIdSequence.newInstanceId();
- }
- return mInstanceId;
- }
-
- /**
* The UiEvent enums that this class can log.
*/
enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum {
@@ -418,7 +384,9 @@ public class EventLog {
@UiEvent(doc = "Sharesheet app share ranking timed out.")
SHARESHEET_APP_SHARE_RANKING_TIMEOUT(831),
@UiEvent(doc = "Sharesheet empty direct share row.")
- SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828);
+ SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828),
+ @UiEvent(doc = "Shareousel payload item toggled")
+ SHARESHEET_PAYLOAD_TOGGLED(1662);
private final int mId;
SharesheetStandardEvent(int id) {
@@ -439,6 +407,9 @@ public class EventLog {
case ContentPreviewType.CONTENT_PREVIEW_FILE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
case ContentPreviewType.CONTENT_PREVIEW_TEXT:
+ case ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION:
+ return FrameworkStatsLog
+ .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA;
default:
return FrameworkStatsLog
.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
@@ -488,52 +459,4 @@ public class EventLog {
return 0;
}
}
-
- private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger {
- @Override
- public void write(
- int frameworkEventId,
- int appEventId,
- String packageName,
- int instanceId,
- String mimeType,
- int numAppProvidedDirectTargets,
- int numAppProvidedAppTargets,
- boolean isWorkProfile,
- int previewType,
- int intentType,
- int numCustomActions,
- boolean modifyShareActionProvided) {
- FrameworkStatsLog.write(
- frameworkEventId,
- /* event_id = 1 */ appEventId,
- /* package_name = 2 */ packageName,
- /* instance_id = 3 */ instanceId,
- /* mime_type = 4 */ mimeType,
- /* num_app_provided_direct_targets */ numAppProvidedDirectTargets,
- /* num_app_provided_app_targets */ numAppProvidedAppTargets,
- /* is_workprofile */ isWorkProfile,
- /* previewType = 8 */ previewType,
- /* intentType = 9 */ intentType,
- /* num_provided_custom_actions = 10 */ numCustomActions,
- /* modify_share_action_provided = 11 */ modifyShareActionProvided);
- }
-
- @Override
- public void write(
- int frameworkEventId,
- int appEventId,
- String packageName,
- int instanceId,
- int positionPicked,
- boolean isPinned) {
- FrameworkStatsLog.write(
- frameworkEventId,
- /* event_id = 1 */ appEventId,
- /* package_name = 2 */ packageName,
- /* instance_id = 3 */ instanceId,
- /* position_picked = 4 */ positionPicked,
- /* is_pinned = 5 */ isPinned);
- }
- }
}
diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt
new file mode 100644
index 00000000..73af7d37
--- /dev/null
+++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.intentresolver.logging
+
+import com.android.internal.logging.InstanceId
+import com.android.internal.logging.InstanceIdSequence
+import com.android.internal.logging.MetricsLogger
+import com.android.internal.logging.UiEventLogger
+import com.android.internal.logging.UiEventLoggerImpl
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+interface EventLogModule {
+
+ @Binds @ActivityRetainedScoped fun eventLog(value: EventLogImpl): EventLog
+
+ companion object {
+ @Provides
+ fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId()
+
+ @Provides fun uiEventLogger(): UiEventLogger = UiEventLoggerImpl()
+
+ @Provides fun frameworkLogger(): FrameworkStatsLogger = object : FrameworkStatsLogger {}
+
+ @Provides fun metricsLogger(): MetricsLogger = MetricsLogger()
+ }
+}
diff --git a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt
new file mode 100644
index 00000000..6508d305
--- /dev/null
+++ b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt
@@ -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.intentresolver.logging
+
+import com.android.internal.util.FrameworkStatsLog
+
+/** A documenting annotation for FrameworkStatsLog methods and their associated UiEvents. */
+internal annotation class ForUiEvent(vararg val uiEventId: Int)
+
+/** Isolates the specific method signatures to use for each of the logged UiEvents. */
+interface FrameworkStatsLogger {
+
+ @ForUiEvent(FrameworkStatsLog.SHARESHEET_STARTED)
+ fun write(
+ frameworkEventId: Int,
+ appEventId: Int,
+ packageName: String?,
+ instanceId: Int,
+ mimeType: String?,
+ numAppProvidedDirectTargets: Int,
+ numAppProvidedAppTargets: Int,
+ isWorkProfile: Boolean,
+ previewType: Int,
+ intentType: Int,
+ numCustomActions: Int,
+ modifyShareActionProvided: Boolean,
+ ) {
+ FrameworkStatsLog.write(
+ frameworkEventId, /* event_id = 1 */
+ appEventId, /* package_name = 2 */
+ packageName, /* instance_id = 3 */
+ instanceId, /* mime_type = 4 */
+ mimeType, /* num_app_provided_direct_targets */
+ numAppProvidedDirectTargets, /* num_app_provided_app_targets */
+ numAppProvidedAppTargets, /* is_workprofile */
+ isWorkProfile, /* previewType = 8 */
+ previewType, /* intentType = 9 */
+ intentType, /* num_provided_custom_actions = 10 */
+ numCustomActions, /* modify_share_action_provided = 11 */
+ modifyShareActionProvided
+ )
+ }
+
+ @ForUiEvent(FrameworkStatsLog.RANKING_SELECTED)
+ fun write(
+ frameworkEventId: Int,
+ appEventId: Int,
+ packageName: String?,
+ instanceId: Int,
+ positionPicked: Int,
+ isPinned: Boolean,
+ ) {
+ FrameworkStatsLog.write(
+ frameworkEventId, /* event_id = 1 */
+ appEventId, /* package_name = 2 */
+ packageName, /* instance_id = 3 */
+ instanceId, /* position_picked = 4 */
+ positionPicked, /* is_pinned = 5 */
+ isPinned
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
index ff2d6a0f..4871ef4d 100644
--- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
@@ -16,11 +16,11 @@
package com.android.intentresolver.model;
-import android.annotation.Nullable;
import android.app.usage.UsageStatsManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.BadParcelableException;
@@ -30,12 +30,14 @@ import android.os.Message;
import android.os.UserHandle;
import android.util.Log;
-import com.android.intentresolver.logging.EventLog;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverActivity;
+import com.android.intentresolver.ResolverListController;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.logging.EventLog;
-import java.text.Collator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -75,6 +77,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
private EventLog mEventLog;
protected final Handler mHandler = new Handler(Looper.getMainLooper()) {
+ @Override
public void handleMessage(Message msg) {
switch (msg.what) {
case RANKER_SERVICE_RESULT:
@@ -132,7 +135,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
user,
(UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE));
}
- mAzComparator = new AzInfoComparator(launchedFromContext);
+ mAzComparator = new ResolveInfoAzInfoComparator(launchedFromContext);
mPromoteToFirst = promoteToFirst;
}
@@ -200,8 +203,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
}
if (mHttp) {
- final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match);
- final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match);
+ final boolean lhsSpecific = isSpecificUriMatch(lhs.match);
+ final boolean rhsSpecific = isSpecificUriMatch(rhs.match);
if (lhsSpecific != rhsSpecific) {
return lhsSpecific ? -1 : 1;
}
@@ -223,13 +226,20 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
return compare(lhs, rhs);
}
+ /** Determine whether a given match result is considered "specific" in our application. */
+ public static final boolean isSpecificUriMatch(int match) {
+ match = (match & IntentFilter.MATCH_CATEGORY_MASK);
+ return match >= IntentFilter.MATCH_CATEGORY_HOST
+ && match <= IntentFilter.MATCH_CATEGORY_PATH;
+ }
+
/**
* Delegated to when used as a {@link Comparator<ResolvedComponentInfo>} if there is not a
* special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in
* {@link ResolvedComponentInfo#getResolveInfoAt(int)} from the parameters of {@link
* #compare(ResolvedComponentInfo, ResolvedComponentInfo)}
*/
- abstract int compare(ResolveInfo lhs, ResolveInfo rhs);
+ public abstract int compare(ResolveInfo lhs, ResolveInfo rhs);
/**
* Computes features for each target. This will be called before calls to {@link
@@ -245,7 +255,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
}
/** Implementation of compute called after {@link #beforeCompute()}. */
- abstract void doCompute(List<ResolvedComponentInfo> targets);
+ public abstract void doCompute(List<ResolvedComponentInfo> targets);
/**
* Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo}
@@ -254,12 +264,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
public abstract float getScore(TargetInfo targetInfo);
/** Handles result message sent to mHandler. */
- abstract void handleResultMessage(Message message);
+ public abstract void handleResultMessage(Message message);
/**
* Reports to UsageStats what was chosen.
*/
- public final void updateChooserCounts(String packageName, UserHandle user, String action) {
+ public void updateChooserCounts(String packageName, UserHandle user, String action) {
if (mUsmMap.containsKey(user)) {
mUsmMap.get(user).reportChooserSelection(
packageName,
@@ -303,24 +313,4 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC
mAfterCompute = null;
}
- /**
- * Sort intents alphabetically based on package name.
- */
- class AzInfoComparator implements Comparator<ResolveInfo> {
- Collator mCollator;
- AzInfoComparator(Context context) {
- mCollator = Collator.getInstance(context.getResources().getConfiguration().locale);
- }
-
- @Override
- public int compare(ResolveInfo lhsp, ResolveInfo rhsp) {
- if (lhsp == null) {
- return -1;
- } else if (rhsp == null) {
- return 1;
- }
- return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName);
- }
- }
-
}
diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
index 621ae306..c6de3260 100644
--- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
@@ -18,7 +18,6 @@ package com.android.intentresolver.model;
import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH;
-import android.annotation.Nullable;
import android.app.prediction.AppPredictor;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
@@ -31,9 +30,12 @@ import android.os.Message;
import android.os.UserHandle;
import android.util.Log;
-import com.android.intentresolver.logging.EventLog;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.shortcuts.ScopedAppTargetListCallback;
import com.google.android.collect.Lists;
@@ -85,12 +87,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
}
@Override
- int compare(ResolveInfo lhs, ResolveInfo rhs) {
+ public int compare(ResolveInfo lhs, ResolveInfo rhs) {
return mComparatorModel.getComparator().compare(lhs, rhs);
}
@Override
- void doCompute(List<ResolvedComponentInfo> targets) {
+ public void doCompute(List<ResolvedComponentInfo> targets) {
if (targets.isEmpty()) {
mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT);
return;
@@ -105,33 +107,48 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
.setClassName(target.name.getClassName())
.build());
}
- mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(),
- sortedAppTargets -> {
- if (sortedAppTargets.isEmpty()) {
- Log.i(TAG, "AppPredictionService disabled. Using resolver.");
- // APS for chooser is disabled. Fallback to resolver.
- mResolverRankerService =
- new ResolverRankerServiceResolverComparator(
- mContext,
- mIntent,
- mReferrerPackage,
- () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT),
- getEventLog(),
- mUser,
- mPromoteToFirst);
- mComparatorModel = buildUpdatedModel();
- mResolverRankerService.compute(targets);
- } else {
- Log.i(TAG, "AppPredictionService response received");
- // Skip sending to Handler which takes extra time to dispatch messages.
- handleResult(sortedAppTargets);
- }
- }
- );
+ try {
+ mAppPredictor.sortTargets(
+ appTargets,
+ Executors.newSingleThreadExecutor(),
+ new ScopedAppTargetListCallback(
+ mContext,
+ sortedAppTargets -> {
+ onAppTargetsSorted(targets, sortedAppTargets);
+ return kotlin.Unit.INSTANCE;
+ }).toConsumer()
+ );
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Couldn't sort targets with AppPredictionService", e);
+ }
+ }
+
+ private void onAppTargetsSorted(
+ List<ResolvedComponentInfo> targets, List<AppTarget> sortedAppTargets) {
+ if (sortedAppTargets.isEmpty()) {
+ Log.i(TAG, "AppPredictionService disabled. Using resolver.");
+ // APS for chooser is disabled. Fallback to resolver.
+ mResolverRankerService =
+ new ResolverRankerServiceResolverComparator(
+ mContext,
+ mIntent,
+ mReferrerPackage,
+ () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT),
+ getEventLog(),
+ mUser,
+ mPromoteToFirst);
+ mComparatorModel = buildUpdatedModel();
+ mResolverRankerService.compute(targets);
+ } else {
+ Log.i(TAG, "AppPredictionService response received");
+ // Skip sending to Handler which takes extra time to dispatch
+ // messages.
+ handleResult(sortedAppTargets);
+ }
}
@Override
- void handleResultMessage(Message msg) {
+ public void handleResultMessage(Message msg) {
// Null value is okay if we have defaulted to the ResolverRankerService.
if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) {
final List<AppTarget> sortedAppTargets = (List<AppTarget>) msg.obj;
@@ -279,8 +296,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser)
.setClassName(targetComponent.getClassName())
.build();
- mAppPredictor.notifyAppTargetEvent(
- new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build());
+ try {
+ mAppPredictor.notifyAppTargetEvent(
+ new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build());
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Couldn't send feedback to AppPredictionService", e);
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java
new file mode 100644
index 00000000..411d0c6e
--- /dev/null
+++ b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.model;
+
+import android.content.Context;
+import android.content.pm.ResolveInfo;
+
+import java.text.Collator;
+import java.util.Comparator;
+
+/**
+ * Sort intents alphabetically based on package name.
+ */
+public class ResolveInfoAzInfoComparator<T extends ResolveInfo> implements Comparator<T> {
+ Collator mCollator;
+
+ public ResolveInfoAzInfoComparator(Context context) {
+ mCollator = Collator.getInstance(context.getResources().getConfiguration().locale);
+ }
+
+ @Override
+ public int compare(ResolveInfo lhsp, ResolveInfo rhsp) {
+ if (lhsp == null) {
+ return -1;
+ } else if (rhsp == null) {
+ return 1;
+ }
+ return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName);
+ }
+}
diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
index 7d473660..963091b5 100644
--- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
@@ -17,7 +17,6 @@
package com.android.intentresolver.model;
-import android.annotation.Nullable;
import android.app.usage.UsageStats;
import android.content.ComponentName;
import android.content.Context;
@@ -29,6 +28,7 @@ import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.metrics.LogMaker;
+import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.RemoteException;
@@ -39,14 +39,17 @@ import android.service.resolver.ResolverRankerService;
import android.service.resolver.ResolverTarget;
import android.util.Log;
-import com.android.intentresolver.logging.EventLog;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.logging.EventLog;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.google.android.collect.Lists;
+import java.lang.ref.WeakReference;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Comparator;
@@ -101,9 +104,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
* the userSpace provided by context.
*/
public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent,
- String referrerPackage, Runnable afterCompute,
- EventLog eventLog, UserHandle targetUserSpace,
- ComponentName promoteToFirst) {
+ String referrerPackage, Runnable afterCompute,
+ EventLog eventLog, UserHandle targetUserSpace,
+ ComponentName promoteToFirst) {
this(launchedFromContext, intent, referrerPackage, afterCompute, eventLog,
Lists.newArrayList(targetUserSpace), promoteToFirst);
}
@@ -117,9 +120,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
* different from the userSpace provided by context.
*/
public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent,
- String referrerPackage, Runnable afterCompute,
- EventLog eventLog, List<UserHandle> targetUserSpaceList,
- @Nullable ComponentName promoteToFirst) {
+ String referrerPackage, Runnable afterCompute, EventLog eventLog,
+ List<UserHandle> targetUserSpaceList, @Nullable ComponentName promoteToFirst) {
super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst);
mCollator = Collator.getInstance(
launchedFromContext.getResources().getConfiguration().locale);
@@ -392,20 +394,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
}
public final IResolverRankerResult resolverRankerResult =
- new IResolverRankerResult.Stub() {
- @Override
- public void sendResult(List<ResolverTarget> targets) throws RemoteException {
- if (DEBUG) {
- Log.d(TAG, "Sending Result back to Resolver: " + targets);
- }
- synchronized (mLock) {
- final Message msg = Message.obtain();
- msg.what = RANKER_SERVICE_RESULT;
- msg.obj = targets;
- mHandler.sendMessage(msg);
- }
- }
- };
+ new ResolverRankerResultCallback(mLock, mHandler);
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
@@ -437,6 +426,32 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom
}
}
+ private static class ResolverRankerResultCallback extends IResolverRankerResult.Stub {
+ private final Object mLock;
+ private final WeakReference<Handler> mHandlerRef;
+
+ private ResolverRankerResultCallback(Object lock, Handler handler) {
+ mLock = lock;
+ mHandlerRef = new WeakReference<>(handler);
+ }
+
+ @Override
+ public void sendResult(List<ResolverTarget> targets) throws RemoteException {
+ if (DEBUG) {
+ Log.d(TAG, "Sending Result back to Resolver: " + targets);
+ }
+ synchronized (mLock) {
+ final Message msg = Message.obtain();
+ msg.what = RANKER_SERVICE_RESULT;
+ msg.obj = targets;
+ Handler handler = mHandlerRef.get();
+ if (handler != null) {
+ handler.sendMessage(msg);
+ }
+ }
+ }
+ }
+
@Override
void beforeCompute() {
super.beforeCompute();
diff --git a/java/src/com/android/intentresolver/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt
new file mode 100644
index 00000000..415d5f7d
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.intentresolver.platform
+
+import android.content.pm.PackageManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AppPredictionAvailable
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppPredictionModule {
+
+ /** Eventually replaced with: Optional<AppPredictionRepository>, etc. */
+ @Provides
+ @Singleton
+ @AppPredictionAvailable
+ fun isAppPredictionAvailable(packageManager: PackageManager): Boolean {
+ return packageManager.appPredictionServicePackageName != null
+ }
+}
diff --git a/java/src/com/android/intentresolver/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt
new file mode 100644
index 00000000..24257968
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.ComponentName
+import android.content.res.Resources
+import androidx.annotation.StringRes
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import java.util.Optional
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+internal fun Resources.componentName(@StringRes resId: Int): ComponentName? {
+ check(getResourceTypeName(resId) == "string") { "resId must be a string" }
+ return ComponentName.unflattenFromString(getString(resId))
+}
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ImageEditorModule {
+ /**
+ * The name of the preferred Activity to launch for editing images. This is added to Intents to
+ * edit images using Intent.ACTION_EDIT.
+ */
+ @Provides
+ @Singleton
+ @ImageEditor
+ fun imageEditorComponent(@ApplicationOwned resources: Resources) =
+ Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor))
+}
diff --git a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt
new file mode 100644
index 00000000..1e4b5241
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.ComponentName
+import android.content.res.Resources
+import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import java.util.Optional
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NearbyShareModule {
+
+ @Provides
+ @Singleton
+ @NearbyShare
+ fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) =
+ Optional.ofNullable(
+ ComponentName.unflattenFromString(
+ settings.getStringOrNull(NEARBY_SHARING_COMPONENT)?.ifEmpty { null }
+ ?: resources.getString(R.string.config_defaultNearbySharingComponent),
+ )
+ )
+}
diff --git a/java/src/com/android/intentresolver/platform/SettingsImpl.kt b/java/src/com/android/intentresolver/platform/SettingsImpl.kt
new file mode 100644
index 00000000..c7ff3521
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/SettingsImpl.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.ContentResolver
+import android.provider.Settings
+import javax.inject.Inject
+
+object SettingsImpl {
+ /** An implementation of GlobalSettings which forwards to [Settings.Global] */
+ class Global @Inject constructor(private val contentResolver: ContentResolver) :
+ GlobalSettings {
+ override fun getStringOrNull(name: String): String? {
+ return Settings.Global.getString(contentResolver, name)
+ }
+
+ override fun putString(name: String, value: String): Boolean {
+ return Settings.Global.putString(contentResolver, name, value)
+ }
+ }
+
+ /** An implementation of SecureSettings which forwards to [Settings.Secure] */
+ class Secure @Inject constructor(private val contentResolver: ContentResolver) :
+ SecureSettings {
+ override fun getStringOrNull(name: String): String? {
+ return Settings.Secure.getString(contentResolver, name)
+ }
+
+ override fun putString(name: String, value: String): Boolean {
+ return Settings.Secure.putString(contentResolver, name, value)
+ }
+ }
+
+ /** An implementation of SystemSettings which forwards to [Settings.System] */
+ class System @Inject constructor(private val contentResolver: ContentResolver) :
+ SystemSettings {
+ override fun getStringOrNull(name: String): String? {
+ return Settings.System.getString(contentResolver, name)
+ }
+
+ override fun putString(name: String, value: String): Boolean {
+ return Settings.System.putString(contentResolver, name, value)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/platform/SettingsModule.kt b/java/src/com/android/intentresolver/platform/SettingsModule.kt
new file mode 100644
index 00000000..3d5c50da
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/SettingsModule.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import dagger.Binds
+import dagger.Module
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface SettingsModule {
+ @Binds @Reusable fun globalSettings(settings: SettingsImpl.Global): GlobalSettings
+
+ @Binds @Reusable fun secureSettings(settings: SettingsImpl.Secure): SecureSettings
+
+ @Binds @Reusable fun systemSettings(settings: SettingsImpl.System): SystemSettings
+}
diff --git a/java/src/com/android/intentresolver/platform/SettingsProxy.kt b/java/src/com/android/intentresolver/platform/SettingsProxy.kt
new file mode 100644
index 00000000..d97a0414
--- /dev/null
+++ b/java/src/com/android/intentresolver/platform/SettingsProxy.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+/** A proxy to Settings.Global */
+interface GlobalSettings : SettingsProxy
+
+/** A proxy to Settings.Secure */
+interface SecureSettings : SettingsProxy
+
+/** A proxy to Settings.System */
+interface SystemSettings : SettingsProxy
+
+/** A generic Settings proxy interface */
+sealed interface SettingsProxy {
+
+ /** Returns the String value set for the given settings key, or null if no value exists. */
+ fun getStringOrNull(name: String): String?
+
+ /**
+ * Writes a new string value for the given settings key.
+ *
+ * @return true if the value did not previously exist or was modified
+ */
+ fun putString(name: String, value: String): Boolean
+
+ /**
+ * Returns the Int value for the given settings key or null if no value exists or it cannot be
+ * interpreted as an Int.
+ */
+ fun getIntOrNull(name: String): Int? = getStringOrNull(name)?.toIntOrNull()
+
+ /**
+ * Writes a new int value for the given settings key.
+ *
+ * @return true if the value did not previously exist or was modified
+ */
+ fun putInt(name: String, value: Int): Boolean = putString(name, value.toString())
+
+ /**
+ * Returns the Boolean value for the given settings key or null if no value exists or it cannot
+ * be interpreted as a Boolean.
+ */
+ fun getBooleanOrNull(name: String): Boolean? = getIntOrNull(name)?.let { it != 0 }
+
+ /**
+ * Writes a new Boolean value for the given settings key.
+ *
+ * @return true if the value did not previously exist or was modified
+ */
+ fun putBoolean(name: String, value: Boolean): Boolean = putInt(name, if (value) 1 else 0)
+
+ /**
+ * Returns the Long value for the given settings key or null if no value exists or it cannot be
+ * interpreted as a Long.
+ */
+ fun getLongOrNull(name: String): Long? = getStringOrNull(name)?.toLongOrNull()
+
+ /**
+ * Writes a new Long value for the given settings key.
+ *
+ * @return true if the value did not previously exist or was modified
+ */
+ fun putLong(name: String, value: Long): Boolean = putString(name, value.toString())
+
+ /**
+ * Returns the Float value for the given settings key or null if no value exists or it cannot be
+ * interpreted as a Float.
+ */
+ fun getFloatOrNull(name: String): Float? = getStringOrNull(name)?.toFloatOrNull()
+
+ /**
+ * Writes a new float value for the given settings key.
+ *
+ * @return true if the value did not previously exist or was modified
+ */
+ fun putFloat(name: String, value: Float): Boolean = putString(name, value.toString())
+}
diff --git a/java/src/com/android/intentresolver/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/profiles/AdapterBinder.java
new file mode 100644
index 00000000..f92a140f
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/AdapterBinder.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+/**
+ * Delegate to set up a given adapter and page view to be used together.
+ *
+ * @param <PageViewT> (as in {@link MultiProfilePagerAdapter}).
+ * @param <SinglePageAdapterT> (as in {@link MultiProfilePagerAdapter}).
+ */
+public interface AdapterBinder<PageViewT, SinglePageAdapterT> {
+ /**
+ * The given {@code view} will be associated with the given {@code adapter}. Do any work
+ * necessary to configure them compatibly, introduce them to each other, etc.
+ */
+ void bind(PageViewT view, SinglePageAdapterT adapter);
+}
diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java
index c159243e..677b6366 100644
--- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,7 +14,9 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.profiles;
+
+import static com.android.intentresolver.Flags.keyboardNavigationFix;
import android.content.Context;
import android.os.UserHandle;
@@ -25,9 +27,12 @@ import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.PagerAdapter;
+import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate;
+import com.android.intentresolver.R;
+import com.android.intentresolver.emptystate.EmptyStateProvider;
import com.android.intentresolver.grid.ChooserGridAdapter;
import com.android.intentresolver.measurements.Tracer;
-import com.android.internal.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -37,48 +42,26 @@ import java.util.function.Supplier;
/**
* A {@link PagerAdapter} which describes the work and personal profile share sheet screens.
*/
-@VisibleForTesting
-public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter<
+public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
RecyclerView, ChooserGridAdapter, ChooserListAdapter> {
private static final int SINGLE_CELL_SPAN_SIZE = 1;
private final ChooserProfileAdapterBinder mAdapterBinder;
private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier;
- ChooserMultiProfilePagerAdapter(
- Context context,
- ChooserGridAdapter adapter,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle,
- int maxTargetsPerRow) {
- this(
- context,
- new ChooserProfileAdapterBinder(maxTargetsPerRow),
- ImmutableList.of(adapter),
- emptyStateProvider,
- workProfileQuietModeChecker,
- /* defaultProfile= */ 0,
- workProfileUserHandle,
- cloneProfileUserHandle,
- new BottomPaddingOverrideSupplier(context));
- }
-
- ChooserMultiProfilePagerAdapter(
+ public ChooserMultiProfilePagerAdapter(
Context context,
- ChooserGridAdapter personalAdapter,
- ChooserGridAdapter workAdapter,
+ ImmutableList<TabConfig<ChooserGridAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
int maxTargetsPerRow) {
this(
context,
new ChooserProfileAdapterBinder(maxTargetsPerRow),
- ImmutableList.of(personalAdapter, workAdapter),
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -90,24 +73,23 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
private ChooserMultiProfilePagerAdapter(
Context context,
ChooserProfileAdapterBinder adapterBinder,
- ImmutableList<ChooserGridAdapter> gridAdapters,
+ ImmutableList<TabConfig<ChooserGridAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
super(
- context,
- gridAdapter -> gridAdapter.getListAdapter(),
+ gridAdapter -> gridAdapter.getListAdapter(),
adapterBinder,
- gridAdapters,
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
workProfileUserHandle,
cloneProfileUserHandle,
- () -> makeProfileView(context),
+ () -> makeProfileView(context),
bottomPaddingOverrideSupplier);
mAdapterBinder = adapterBinder;
mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier;
@@ -119,6 +101,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
public void setEmptyStateBottomOffset(int bottomOffset) {
mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset);
+ setupContainerPadding();
}
/**
@@ -127,14 +110,26 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
*/
public void setIsCollapsed(boolean isCollapsed) {
for (int i = 0, size = getItemCount(); i < size; i++) {
- getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);
+ getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);
+ }
+ }
+
+ /**
+ * Set enabled status for all targets in all profiles.
+ */
+ public void setTargetsEnabled(boolean isEnabled) {
+ for (int i = 0, size = getItemCount(); i < size; i++) {
+ getPageAdapterForIndex(i).getListAdapter().setTargetsEnabled(isEnabled);
}
}
private static ViewGroup makeProfileView(Context context) {
LayoutInflater inflater = LayoutInflater.from(context);
- ViewGroup rootView = (ViewGroup) inflater.inflate(
- R.layout.chooser_list_per_profile, null, false);
+ ViewGroup rootView =
+ (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false);
+ if (!keyboardNavigationFix()) {
+ rootView.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ }
RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list);
recyclerView.setAccessibilityDelegateCompat(
new ChooserRecyclerViewAccessibilityDelegate(recyclerView));
@@ -142,19 +137,36 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
}
@Override
- boolean rebuildActiveTab(boolean doPostProcessing) {
+ public boolean onHandlePackagesChanged(
+ ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) {
+ // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case?
+ getActiveListAdapter().notifyDataSetChanged();
+ return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile);
+ }
+
+ @Override
+ protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) {
if (doPostProcessing) {
- Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle());
+ Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle());
}
- return super.rebuildActiveTab(doPostProcessing);
+ return super.rebuildTab(listAdapter, doPostProcessing);
}
- @Override
- boolean rebuildInactiveTab(boolean doPostProcessing) {
- if (getItemCount() != 1 && doPostProcessing) {
- Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle());
+ /** Apply the specified {@code height} as the footer in each tab's adapter. */
+ public void setFooterHeightInEveryAdapter(int height) {
+ for (int i = 0; i < getItemCount(); ++i) {
+ getPageAdapterForIndex(i).setFooterHeight(height);
+ }
+ }
+
+ /** Cleanup system resources */
+ public void destroy() {
+ for (int i = 0, count = getItemCount(); i < count; i++) {
+ ChooserGridAdapter adapter = getPageAdapterForIndex(i);
+ if (adapter != null) {
+ adapter.getListAdapter().onDestroy();
+ }
}
- return super.rebuildInactiveTab(doPostProcessing);
}
private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
@@ -169,6 +181,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda
mBottomOffset = bottomOffset;
}
+ @Override
public Optional<Integer> get() {
int initialBottomPadding = mContext.getResources().getDimensionPixelSize(
R.dimen.resolver_empty_state_container_padding_bottom);
diff --git a/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java
new file mode 100644
index 00000000..11a6caca
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java
@@ -0,0 +1,705 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+import android.annotation.Nullable;
+import android.os.Trace;
+import android.os.UserHandle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TabHost;
+import android.widget.TextView;
+
+import androidx.viewpager.widget.PagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.emptystate.EmptyState;
+import com.android.intentresolver.emptystate.EmptyStateProvider;
+import com.android.intentresolver.shared.model.Profile;
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet).
+ *
+ * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter
+ * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in
+ * the per-profile records.
+ * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to
+ * control the contents of a given per-profile list. This is provided for convenience, since it must
+ * be possible to get the list adapter from the page adapter via our
+ * <code>mListAdapterExtractor</code>.
+ */
+public class MultiProfilePagerAdapter<
+ PageViewT extends ViewGroup,
+ SinglePageAdapterT,
+ ListAdapterT extends ResolverListAdapter> extends PagerAdapter {
+
+ public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal();
+ public static final int PROFILE_WORK = Profile.Type.WORK.ordinal();
+
+ // Removed, must be constants. This is only used for linting anyway.
+ // @IntDef({PROFILE_PERSONAL, PROFILE_WORK})
+ public @interface ProfileType {}
+
+ private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor;
+ private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder;
+ private final Supplier<ViewGroup> mPageViewInflater;
+
+ private final ImmutableList<ProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems;
+
+ private final EmptyStateProvider mEmptyStateProvider;
+ private final UserHandle mWorkProfileUserHandle;
+ private final UserHandle mCloneProfileUserHandle;
+ private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet.
+
+ private final Set<Integer> mLoadedPages;
+ private int mCurrentPage;
+ private OnProfileSelectedListener mOnProfileSelectedListener;
+
+ protected MultiProfilePagerAdapter(
+ Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor,
+ AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
+ ImmutableList<TabConfig<SinglePageAdapterT>> tabs,
+ EmptyStateProvider emptyStateProvider,
+ Supplier<Boolean> workProfileQuietModeChecker,
+ @ProfileType int defaultProfile,
+ UserHandle workProfileUserHandle,
+ UserHandle cloneProfileUserHandle,
+ Supplier<ViewGroup> pageViewInflater,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ mLoadedPages = new HashSet<>();
+ mWorkProfileUserHandle = workProfileUserHandle;
+ mCloneProfileUserHandle = cloneProfileUserHandle;
+ mEmptyStateProvider = emptyStateProvider;
+ mWorkProfileQuietModeChecker = workProfileQuietModeChecker;
+
+ mListAdapterExtractor = listAdapterExtractor;
+ mAdapterBinder = adapterBinder;
+ mPageViewInflater = pageViewInflater;
+
+ ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items =
+ new ImmutableList.Builder<>();
+ for (TabConfig<SinglePageAdapterT> tab : tabs) {
+ // TODO: consider representing tabConfig in a different data structure that can ensure
+ // uniqueness of their profile assignments (while still respecting the client's
+ // requested tab order).
+ items.add(
+ createProfileDescriptor(
+ tab.mProfile,
+ tab.mTabLabel,
+ tab.mTabAccessibilityLabel,
+ tab.mTabTag,
+ tab.mPageAdapter,
+ containerBottomPaddingOverrideSupplier));
+ }
+ mItems = items.build();
+
+ mCurrentPage =
+ hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0;
+ }
+
+ private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor(
+ @ProfileType int profile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
+ SinglePageAdapterT adapter,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ return new ProfileDescriptor<>(
+ profile,
+ tabLabel,
+ tabAccessibilityLabel,
+ tabTag,
+ mPageViewInflater.get(),
+ adapter,
+ containerBottomPaddingOverrideSupplier);
+ }
+
+ private boolean hasPageForIndex(int pageIndex) {
+ return (pageIndex >= 0) && (pageIndex < getCount());
+ }
+
+ public final boolean hasPageForProfile(@ProfileType int profile) {
+ return hasPageForIndex(getPageNumberForProfile(profile));
+ }
+
+ private @ProfileType int getProfileForPageNumber(int position) {
+ if (hasPageForIndex(position)) {
+ return mItems.get(position).mProfile;
+ }
+ return -1;
+ }
+
+ public int getPageNumberForProfile(@ProfileType int profile) {
+ for (int i = 0; i < mItems.size(); ++i) {
+ if (profile == mItems.get(i).mProfile) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private ListAdapterT getListAdapterForPageNumber(int pageNumber) {
+ SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber);
+ if (pageAdapter == null) {
+ return null;
+ }
+ return mListAdapterExtractor.apply(pageAdapter);
+ }
+
+ private @ProfileType int getProfileForUserHandle(UserHandle userHandle) {
+ if (userHandle.equals(getCloneUserHandle())) {
+ // TODO: can we push this special case elsewhere -- e.g., when we check against each
+ // list adapter's user handle in the loop below, could we instead ask the list adapter
+ // whether it "represents" the queried user handle, and have the personal list adapter
+ // return true because it knows it's also associated with the clone profile? Or if we
+ // don't want to make modifications to the list adapter, maybe we could at least specify
+ // it in our per-page configuration data that we use to build our tabs/pages, and then
+ // maintain the relevant bookkeeping in our own ProfileDescriptor?
+ return PROFILE_PERSONAL;
+ }
+ for (int i = 0; i < mItems.size(); ++i) {
+ ListAdapterT listAdapter = getListAdapterForPageNumber(i);
+ if (listAdapter.getUserHandle().equals(userHandle)) {
+ return mItems.get(i).mProfile;
+ }
+ }
+ return -1;
+ }
+
+ private int getPageNumberForUserHandle(UserHandle userHandle) {
+ return getPageNumberForProfile(getProfileForUserHandle(userHandle));
+ }
+
+ /**
+ * Returns the {@link ListAdapterT} instance of the profile that represents
+ * <code>userHandle</code>. If there is no such adapter for the specified
+ * <code>userHandle</code>, returns {@code null}.
+ * <p>For example, if there is a work profile on the device with user id 10, calling this method
+ * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}.
+ */
+ @Nullable
+ public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) {
+ return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle));
+ }
+
+ @Nullable
+ private ProfileDescriptor<PageViewT, SinglePageAdapterT> getDescriptorForUserHandle(
+ UserHandle userHandle) {
+ return getItem(getPageNumberForUserHandle(userHandle));
+ }
+
+ private int getPageNumberForTabTag(String tag) {
+ for (int i = 0; i < mItems.size(); ++i) {
+ if (Objects.equals(mItems.get(i).mTabTag, tag)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void updateActiveTabStyle(TabHost tabHost) {
+ int currentTab = tabHost.getCurrentTab();
+
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ // TODO: can we avoid this downcast by pushing our knowledge of the intended view type
+ // somewhere else?
+ TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber);
+ tabText.setSelected(currentTab == pageNumber);
+ }
+ }
+
+ public void setupProfileTabs(
+ LayoutInflater layoutInflater,
+ TabHost tabHost,
+ ViewPager viewPager,
+ int tabButtonLayoutResId,
+ int tabPageContentViewId,
+ Runnable onTabChangeListener,
+ OnProfileSelectedListener clientOnProfileSelectedListener) {
+ tabHost.setup();
+ tabHost.getTabWidget().removeAllViews();
+ viewPager.setSaveEnabled(false);
+
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = mItems.get(pageNumber);
+ Button profileButton = (Button) layoutInflater.inflate(
+ tabButtonLayoutResId, tabHost.getTabWidget(), false);
+ profileButton.setText(descriptor.mTabLabel);
+ profileButton.setContentDescription(descriptor.mTabAccessibilityLabel);
+
+ TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag)
+ .setContent(tabPageContentViewId)
+ .setIndicator(profileButton);
+ tabHost.addTab(profileTabSpec);
+ }
+
+ tabHost.getTabWidget().setVisibility(View.VISIBLE);
+
+ updateActiveTabStyle(tabHost);
+
+ tabHost.setOnTabChangedListener(tabTag -> {
+ updateActiveTabStyle(tabHost);
+
+ int pageNumber = getPageNumberForTabTag(tabTag);
+ if (pageNumber >= 0) {
+ viewPager.setCurrentItem(pageNumber);
+ }
+ onTabChangeListener.run();
+ });
+
+ viewPager.setVisibility(View.VISIBLE);
+ tabHost.setCurrentTab(getCurrentPage());
+ mOnProfileSelectedListener =
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {
+ tabHost.setCurrentTab(pageNumber);
+ clientOnProfileSelectedListener.onProfilePageSelected(
+ profileId, pageNumber);
+ }
+
+ @Override
+ public void onProfilePageStateChanged(int state) {
+ clientOnProfileSelectedListener.onProfilePageStateChanged(state);
+ }
+ };
+ }
+
+ /**
+ * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets
+ * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed
+ * page and rebuilds the list.
+ */
+ public void setupViewPager(ViewPager viewPager) {
+ viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ MultiProfilePagerAdapter.this.onPageSelected(position);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ if (mOnProfileSelectedListener != null) {
+ mOnProfileSelectedListener.onProfilePageStateChanged(state);
+ }
+ }
+ });
+ viewPager.setAdapter(this);
+ viewPager.setCurrentItem(mCurrentPage);
+ mLoadedPages.add(mCurrentPage);
+ }
+
+ private void onPageSelected(int position) {
+ mCurrentPage = position;
+ if (!mLoadedPages.contains(position)) {
+ rebuildActiveTab(true);
+ mLoadedPages.add(position);
+ }
+ if (mOnProfileSelectedListener != null) {
+ mOnProfileSelectedListener.onProfilePageSelected(
+ getProfileForPageNumber(position), position);
+ }
+ }
+
+ public void clearInactiveProfileCache() {
+ forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber));
+ }
+
+ @Override
+ public final ViewGroup instantiateItem(ViewGroup container, int position) {
+ setupListAdapter(position);
+ final ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(position);
+ container.addView(descriptor.mRootView);
+ return descriptor.mRootView;
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object view) {
+ container.removeView((View) view);
+ }
+
+ @Override
+ public int getCount() {
+ return getItemCount();
+ }
+
+ public int getCurrentPage() {
+ return mCurrentPage;
+ }
+
+ /**
+ * Set active adapter page. A support method for the poayload reselection logic.
+ */
+ public void setCurrentPage(int page) {
+ onPageSelected(page);
+ }
+
+ public final @ProfileType int getActiveProfile() {
+ return getProfileForPageNumber(getCurrentPage());
+ }
+
+ @VisibleForTesting
+ public UserHandle getCurrentUserHandle() {
+ return getActiveListAdapter().getUserHandle();
+ }
+
+ @Override
+ public boolean isViewFromObject(View view, Object object) {
+ return view == object;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return null;
+ }
+
+ public UserHandle getCloneUserHandle() {
+ return mCloneProfileUserHandle;
+ }
+
+ /**
+ * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>.
+ * <ul>
+ * <li>For a device with only one user, <code>pageIndex</code> value of
+ * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li>
+ * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would
+ * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of
+ * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li>
+ * </ul>
+ */
+ @Nullable
+ private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) {
+ if (!hasPageForIndex(pageIndex)) {
+ return null;
+ }
+ return mItems.get(pageIndex);
+ }
+
+ private ViewGroup getEmptyStateView(int pageIndex) {
+ return getItem(pageIndex).getEmptyStateView();
+ }
+
+ public ViewGroup getActiveEmptyStateView() {
+ return getEmptyStateView(getCurrentPage());
+ }
+
+ /**
+ * Returns the number of {@link ProfileDescriptor} objects.
+ * <p>For a normal consumer device with only one user returns <code>1</code>.
+ * <p>For a device with a work profile returns <code>2</code>.
+ */
+ public final int getItemCount() {
+ return mItems.size();
+ }
+
+ public final PageViewT getListViewForIndex(int index) {
+ return getItem(index).getView();
+ }
+
+ /**
+ * Returns the adapter of the list view for the relevant page specified by
+ * <code>pageIndex</code>.
+ * <p>This method is meant to be implemented with an implementation-specific return type
+ * depending on the adapter type.
+ */
+ @VisibleForTesting
+ public final SinglePageAdapterT getPageAdapterForIndex(int index) {
+ if (!hasPageForIndex(index)) {
+ return null;
+ }
+ return getItem(index).getAdapter();
+ }
+
+ /**
+ * Performs view-related initialization procedures for the adapter specified
+ * by <code>pageIndex</code>.
+ */
+ public final void setupListAdapter(int pageIndex) {
+ mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex));
+ }
+
+ /**
+ * Returns the {@link ListAdapterT} instance of the profile that is currently visible
+ * to the user.
+ * <p>For example, if the user is viewing the work tab in the share sheet, this method returns
+ * the work profile {@link ListAdapterT}.
+ */
+ @VisibleForTesting
+ public final ListAdapterT getActiveListAdapter() {
+ return getListAdapterForPageNumber(getCurrentPage());
+ }
+
+ public final ListAdapterT getPersonalListAdapter() {
+ return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL));
+ }
+
+ @Nullable
+ public final ListAdapterT getWorkListAdapter() {
+ if (!hasPageForProfile(PROFILE_WORK)) {
+ return null;
+ }
+ return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK));
+ }
+
+ public final SinglePageAdapterT getCurrentRootAdapter() {
+ return getPageAdapterForIndex(getCurrentPage());
+ }
+
+ public final PageViewT getActiveAdapterView() {
+ return getListViewForIndex(getCurrentPage());
+ }
+
+ private boolean anyAdapterHasItems() {
+ for (int i = 0; i < mItems.size(); ++i) {
+ ListAdapterT listAdapter = getListAdapterForPageNumber(i);
+ if (listAdapter.getCount() > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void refreshPackagesInAllTabs() {
+ // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt
+ // first, or if we could just iterate over the tabs in arbitrary order.
+ getActiveListAdapter().handlePackagesChanged();
+ forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged());
+ }
+
+ /**
+ * Notify that there has been a package change which could potentially modify the set of targets
+ * that should be shown in the specified {@code listAdapter}. This <em>may</em> result in
+ * "rebuilding" the target list for that adapter.
+ *
+ * @param listAdapter an adapter that may need to be updated after the package-change event.
+ * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet
+ * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any
+ * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild
+ * will be prompted when we eventually get the broadcast.
+ *
+ * @return whether we're able to proceed with a Sharesheet session after processing this
+ * package-change event. If false, we were able to rebuild the targets but determined that there
+ * aren't any we could present in the UI without the app looking broken, so we should just quit.
+ */
+ public boolean onHandlePackagesChanged(
+ ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) {
+ if (listAdapter == getActiveListAdapter()) {
+ if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
+ && waitingToEnableWorkProfile) {
+ // We have just turned on the work profile and entered the passcode to start it,
+ // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no
+ // point in reloading the list now, since the work profile user is still turning on.
+ return true;
+ }
+
+ boolean listRebuilt = rebuildActiveTab(true);
+ if (listRebuilt) {
+ listAdapter.notifyDataSetChanged();
+ }
+
+ // TODO: shouldn't we check that the inactive tabs are built before declaring that we
+ // have to quit for lack of items?
+ return anyAdapterHasItems();
+ } else {
+ clearInactiveProfileCache();
+ return true;
+ }
+ }
+
+ /**
+ * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs.
+ */
+ public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) {
+ // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as
+ // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to
+ // be able to evaluate the intermediate state of one particular profile tab (i.e. work
+ // profile) that may not generalize well when we have other "inactive tabs." I.e., either we
+ // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only
+ // depend on personal and/or work tabs, or we have to explicitly specify the ones we care
+ // about. It's not the pager-adapter's business to know "which ones we care about," so maybe
+ // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of
+ // autolaunch conditions).
+ boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded();
+ if (includePartialRebuildOfInactiveTabs) {
+ // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start*
+ // loading the inactive tabs even if we're still waiting on the active tab to finish?).
+ boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false);
+ rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs;
+ }
+ return rebuildCompleted;
+ }
+
+ /**
+ * Rebuilds the tab that is currently visible to the user.
+ * <p>Returns {@code true} if rebuild has completed.
+ */
+ public final boolean rebuildActiveTab(boolean doPostProcessing) {
+ Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab");
+ boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing);
+ Trace.endSection();
+ return result;
+ }
+
+ /**
+ * Rebuilds any tabs that are not currently visible to the user.
+ * <p>Returns {@code true} if rebuild has completed in all inactive tabs.
+ */
+ private boolean rebuildInactiveTabs(boolean doPostProcessing) {
+ Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab");
+ AtomicBoolean allRebuildsComplete = new AtomicBoolean(true);
+ forEachInactivePage(pageNumber -> {
+ // Evaluate the rebuild for every inactive page, even if we've already seen some adapter
+ // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false)
+ // and so we already know we'll end up returning false for the batch.
+ // TODO: any particular reason the per-page legacy logic was set up in this order, or
+ // could we possibly short-circuit the rebuild if the tab is already "loaded"?
+ ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber);
+ boolean rebuildInactivePageCompleted =
+ rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded();
+ if (!rebuildInactivePageCompleted) {
+ allRebuildsComplete.set(false);
+ }
+ });
+ Trace.endSection();
+ return allRebuildsComplete.get();
+ }
+
+ protected void forEachPage(Consumer<Integer> pageNumberHandler) {
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ pageNumberHandler.accept(pageNumber);
+ }
+ }
+
+ protected void forEachInactivePage(Consumer<Integer> inactivePageNumberHandler) {
+ forEachPage(pageNumber -> {
+ if (pageNumber != getCurrentPage()) {
+ inactivePageNumberHandler.accept(pageNumber);
+ }
+ });
+ }
+
+ protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) {
+ if (shouldSkipRebuild(activeListAdapter)) {
+ activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true);
+ return false;
+ }
+ return activeListAdapter.rebuildList(doPostProcessing);
+ }
+
+ private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) {
+ EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter);
+ return emptyState != null && emptyState.shouldSkipDataRebuild();
+ }
+
+ /**
+ * The empty state screens are shown according to their priority:
+ * <ol>
+ * <li>(highest priority) cross-profile disabled by policy (handled in
+ * {@link #rebuildTab(ListAdapterT, boolean)})</li>
+ * <li>no apps available</li>
+ * <li>(least priority) work is off</li>
+ * </ol>
+ *
+ * The intention is to prevent the user from having to turn
+ * the work profile on if there will not be any apps resolved
+ * anyway.
+ *
+ * TODO: move this comment to the place where we configure our composite provider.
+ */
+ public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) {
+ final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter);
+
+ if (emptyState == null) {
+ return;
+ }
+
+ emptyState.onEmptyStateShown();
+
+ View.OnClickListener clickListener = null;
+
+ if (emptyState.getButtonClickListener() != null) {
+ clickListener = v -> emptyState.getButtonClickListener().onClick(() -> {
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(listAdapter.getUserHandle());
+ descriptor.mEmptyStateUi.showSpinner();
+ });
+ }
+
+ showEmptyState(listAdapter, emptyState, clickListener);
+ }
+
+ private void showEmptyState(
+ ListAdapterT activeListAdapter,
+ EmptyState emptyState,
+ View.OnClickListener buttonOnClick) {
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(activeListAdapter.getUserHandle());
+ descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick);
+ activeListAdapter.markTabLoaded();
+ }
+
+ /**
+ * Sets up the padding of the view containing the empty state screens for the current adapter
+ * view.
+ */
+ protected final void setupContainerPadding() {
+ getItem(getCurrentPage()).setupContainerPadding();
+ }
+
+ public void showListView(ListAdapterT activeListAdapter) {
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(activeListAdapter.getUserHandle());
+ descriptor.mEmptyStateUi.hide();
+ }
+
+ /**
+ * @return whether any "inactive" tab's adapter would show an empty-state screen in our current
+ * application state.
+ */
+ public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() {
+ AtomicBoolean anyEmpty = new AtomicBoolean(false);
+ // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"?
+ forEachInactivePage(pageNumber -> {
+ if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) {
+ anyEmpty.set(true);
+ }
+ });
+ return anyEmpty.get();
+ }
+
+ public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) {
+ int count = listAdapter.getUnfilteredCount();
+ return (count == 0 && listAdapter.getPlaceholderCount() == 0)
+ || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
+ && mWorkProfileQuietModeChecker.get());
+ }
+
+}
diff --git a/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java
new file mode 100644
index 00000000..e6299954
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+import androidx.viewpager.widget.ViewPager;
+
+/** Listener interface for changes between the per-profile UI tabs. */
+public interface OnProfileSelectedListener {
+ /**
+ * Callback for when the user changes the active tab.
+ * <p>This callback is only called when the intent resolver or share sheet shows
+ * more than one profile.
+ *
+ * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL}
+ * if the personal profile tab was selected or {@link #PROFILE_WORK} if the
+ * work profile tab
+ * was selected.
+ */
+ void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber);
+
+
+ /**
+ * Callback for when the scroll state changes. Useful for discovering when the user begins
+ * dragging, when the pager is automatically settling to the current page, or when it is
+ * fully stopped/idle.
+ *
+ * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING}
+ * or {@link ViewPager#SCROLL_STATE_SETTLING}
+ * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged
+ */
+ void onProfilePageStateChanged(int state);
+}
diff --git a/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java
new file mode 100644
index 00000000..7989551a
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+/**
+ * Listener for when the user switches on the work profile from the work tab.
+ */
+public interface OnSwitchOnWorkSelectedListener {
+ /**
+ * Callback for when the user switches on the work profile from the work tab.
+ */
+ void onSwitchOnWorkSelected();
+}
diff --git a/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java
new file mode 100644
index 00000000..61c7c670
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+import android.view.ViewGroup;
+
+import com.android.intentresolver.emptystate.EmptyStateUiHelper;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+// TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager"
+// should be the owner of all per-profile data (especially now that the API is generic)?
+class ProfileDescriptor<PageViewT, SinglePageAdapterT> {
+ final @MultiProfilePagerAdapter.ProfileType int mProfile;
+ final String mTabLabel;
+ final String mTabAccessibilityLabel;
+ final String mTabTag;
+
+ final ViewGroup mRootView;
+ final EmptyStateUiHelper mEmptyStateUi;
+
+ // TODO: post-refactoring, we may not need to retain these ivars directly (since they may
+ // be encapsulated within the `EmptyStateUiHelper`?).
+ private final ViewGroup mEmptyStateView;
+
+ private final SinglePageAdapterT mAdapter;
+
+ public SinglePageAdapterT getAdapter() {
+ return mAdapter;
+ }
+
+ public PageViewT getView() {
+ return mView;
+ }
+
+ private final PageViewT mView;
+
+ ProfileDescriptor(
+ @MultiProfilePagerAdapter.ProfileType int forProfile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
+ ViewGroup rootView,
+ SinglePageAdapterT adapter,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ mProfile = forProfile;
+ mTabLabel = tabLabel;
+ mTabAccessibilityLabel = tabAccessibilityLabel;
+ mTabTag = tabTag;
+ mRootView = rootView;
+ mAdapter = adapter;
+ mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state);
+ mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list);
+ mEmptyStateUi = new EmptyStateUiHelper(
+ rootView,
+ com.android.internal.R.id.resolver_list,
+ containerBottomPaddingOverrideSupplier);
+ }
+
+ protected ViewGroup getEmptyStateView() {
+ return mEmptyStateView;
+ }
+
+ public void setupContainerPadding() {
+ mEmptyStateUi.setupContainerPadding();
+ }
+}
diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java
index 85d97ad5..0c669510 100644
--- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.profiles;
import android.content.Context;
import android.os.UserHandle;
@@ -24,7 +24,9 @@ import android.widget.ListView;
import androidx.viewpager.widget.PagerAdapter;
-import com.android.internal.annotations.VisibleForTesting;
+import com.android.intentresolver.R;
+import com.android.intentresolver.ResolverListAdapter;
+import com.android.intentresolver.emptystate.EmptyStateProvider;
import com.google.common.collect.ImmutableList;
@@ -34,40 +36,20 @@ import java.util.function.Supplier;
/**
* A {@link PagerAdapter} which describes the work and personal profile intent resolver screens.
*/
-@VisibleForTesting
public class ResolverMultiProfilePagerAdapter extends
- GenericMultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> {
+ MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> {
private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier;
- ResolverMultiProfilePagerAdapter(
- Context context,
- ResolverListAdapter adapter,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle) {
+ public ResolverMultiProfilePagerAdapter(Context context,
+ ImmutableList<TabConfig<ResolverListAdapter>> tabs,
+ EmptyStateProvider emptyStateProvider,
+ Supplier<Boolean> workProfileQuietModeChecker,
+ @ProfileType int defaultProfile,
+ UserHandle workProfileUserHandle,
+ UserHandle cloneProfileUserHandle) {
this(
context,
- ImmutableList.of(adapter),
- emptyStateProvider,
- workProfileQuietModeChecker,
- /* defaultProfile= */ 0,
- workProfileUserHandle,
- cloneProfileUserHandle,
- new BottomPaddingOverrideSupplier());
- }
-
- ResolverMultiProfilePagerAdapter(Context context,
- ResolverListAdapter personalAdapter,
- ResolverListAdapter workAdapter,
- EmptyStateProvider emptyStateProvider,
- Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
- UserHandle workProfileUserHandle,
- UserHandle cloneProfileUserHandle) {
- this(
- context,
- ImmutableList.of(personalAdapter, workAdapter),
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -78,18 +60,17 @@ public class ResolverMultiProfilePagerAdapter extends
private ResolverMultiProfilePagerAdapter(
Context context,
- ImmutableList<ResolverListAdapter> listAdapters,
+ ImmutableList<TabConfig<ResolverListAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
super(
- context,
listAdapter -> listAdapter,
(listView, bindAdapter) -> listView.setAdapter(bindAdapter),
- listAdapters,
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -105,6 +86,17 @@ public class ResolverMultiProfilePagerAdapter extends
mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault);
}
+ /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */
+ public void clearCheckedItemsInInactiveProfiles() {
+ // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all?
+ forEachInactivePage(pageNumber -> {
+ ListView inactiveListView = getListViewForIndex(pageNumber);
+ if (inactiveListView.getCheckedItemCount() > 0) {
+ inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false);
+ }
+ });
+ }
+
private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
private boolean mUseLayoutWithDefault;
diff --git a/java/src/com/android/intentresolver/profiles/TabConfig.java b/java/src/com/android/intentresolver/profiles/TabConfig.java
new file mode 100644
index 00000000..320f069a
--- /dev/null
+++ b/java/src/com/android/intentresolver/profiles/TabConfig.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles;
+
+public class TabConfig<PageAdapterT> {
+ final @MultiProfilePagerAdapter.ProfileType int mProfile;
+ final String mTabLabel;
+ final String mTabAccessibilityLabel;
+ final String mTabTag;
+ final PageAdapterT mPageAdapter;
+
+ public TabConfig(
+ @MultiProfilePagerAdapter.ProfileType int profile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
+ PageAdapterT pageAdapter) {
+ mProfile = profile;
+ mTabLabel = tabLabel;
+ mTabAccessibilityLabel = tabAccessibilityLabel;
+ mTabTag = tabTag;
+ mPageAdapter = pageAdapter;
+ }
+}
diff --git a/java/src/com/android/intentresolver/shared/model/ActivityModel.kt b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt
new file mode 100644
index 00000000..1a57759d
--- /dev/null
+++ b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 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.intentresolver.shared.model
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+import com.android.intentresolver.data.model.ANDROID_APP_SCHEME
+import com.android.intentresolver.ext.readParcelable
+import com.android.intentresolver.ext.requireParcelable
+import java.util.Objects
+
+/** Contains Activity-scope information about the state when started. */
+data class ActivityModel(
+ /** The [Intent] received by the app */
+ val intent: Intent,
+ /** The identifier for the sending app and user */
+ val launchedFromUid: Int,
+ /** The package of the sending app */
+ val launchedFromPackage: String,
+ /** The referrer as supplied to the activity. */
+ val referrer: Uri?,
+ /** True if the activity is the first activity in the task */
+ val isTaskRoot: Boolean,
+) : Parcelable {
+ constructor(
+ source: Parcel
+ ) : this(
+ intent = source.requireParcelable(),
+ launchedFromUid = source.readInt(),
+ launchedFromPackage = requireNotNull(source.readString()),
+ referrer = source.readParcelable(),
+ isTaskRoot = source.readBoolean(),
+ )
+
+ /** A package name from referrer, if it is an android-app URI */
+ val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority
+
+ override fun describeContents() = 0 /* flags */
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeParcelable(intent, flags)
+ dest.writeInt(launchedFromUid)
+ dest.writeString(launchedFromPackage)
+ dest.writeParcelable(referrer, flags)
+ dest.writeBoolean(isTaskRoot)
+ }
+
+ companion object {
+ @JvmField
+ @Suppress("unused")
+ val CREATOR =
+ object : Parcelable.Creator<ActivityModel> {
+ override fun newArray(size: Int) = arrayOfNulls<ActivityModel>(size)
+
+ override fun createFromParcel(source: Parcel) = ActivityModel(source)
+ }
+
+ @JvmStatic
+ fun createFrom(activity: Activity): ActivityModel {
+ return ActivityModel(
+ activity.intent,
+ activity.launchedFromUid,
+ Objects.requireNonNull<String>(activity.launchedFromPackage),
+ activity.referrer,
+ activity.isTaskRoot,
+ )
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/shared/model/Profile.kt b/java/src/com/android/intentresolver/shared/model/Profile.kt
new file mode 100644
index 00000000..c557c151
--- /dev/null
+++ b/java/src/com/android/intentresolver/shared/model/Profile.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.shared.model
+
+import com.android.intentresolver.shared.model.Profile.Type
+
+/**
+ * Associates [users][User] into a [Type] instance.
+ *
+ * This is a simple abstraction which combines a primary [user][User] with an optional
+ * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being
+ * available where needed.
+ */
+data class Profile(
+ val type: Type,
+ val primary: User,
+ /**
+ * An optional [User] of which contains second instances of some applications installed for the
+ * personal user. This value may only be supplied when creating the PERSONAL profile.
+ */
+ val clone: User? = null
+) {
+
+ init {
+ clone?.apply {
+ require(primary.role == User.Role.PERSONAL) {
+ "clone is not supported for profile=${this@Profile.type} / primary=$primary"
+ }
+ require(role == User.Role.CLONE) { "clone is not a clone user ($this)" }
+ }
+ }
+
+ enum class Type {
+ PERSONAL,
+ WORK,
+ PRIVATE
+ }
+}
diff --git a/java/src/com/android/intentresolver/shared/model/User.kt b/java/src/com/android/intentresolver/shared/model/User.kt
new file mode 100644
index 00000000..b544a390
--- /dev/null
+++ b/java/src/com/android/intentresolver/shared/model/User.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.shared.model
+
+import android.annotation.UserIdInt
+import android.os.UserHandle
+
+/**
+ * A User represents the owner of a distinct set of content.
+ * * maps 1:1 to a UserHandle or UserId (Int) value.
+ * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the
+ * [type] property.
+ *
+ * See
+ * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md)
+ *
+ * ```
+ * val users = listOf(
+ * User(id = 0, role = PERSONAL),
+ * User(id = 10, role = WORK),
+ * User(id = 11, role = CLONE),
+ * User(id = 12, role = PRIVATE),
+ * )
+ * ```
+ */
+data class User(
+ @UserIdInt val id: Int,
+ val role: Role,
+) {
+ val handle: UserHandle = UserHandle.of(id)
+
+ enum class Role {
+ PERSONAL,
+ PRIVATE,
+ WORK,
+ CLONE
+ }
+}
diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
index 82f40b91..c7bd0336 100644
--- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
+++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
@@ -31,37 +31,39 @@ private const val SHARED_TEXT_KEY = "shared_text"
/**
* A factory to create an AppPredictor instance for a profile, if available.
+ *
* @param context, application context
- * @param sharedText, a shared text associated with the Chooser's target intent
- * (see [android.content.Intent.EXTRA_TEXT]).
- * Will be mapped to app predictor's "shared_text" parameter.
- * @param targetIntentFilter, an IntentFilter to match direct share targets against.
- * Will be mapped app predictor's "intent_filter" parameter.
+ * @param sharedText, a shared text associated with the Chooser's target intent (see
+ * [android.content.Intent.EXTRA_TEXT]). Will be mapped to app predictor's "shared_text"
+ * parameter.
+ * @param targetIntentFilter, an IntentFilter to match direct share targets against. Will be mapped
+ * app predictor's "intent_filter" parameter.
*/
class AppPredictorFactory(
private val context: Context,
private val sharedText: String?,
- private val targetIntentFilter: IntentFilter?
+ private val targetIntentFilter: IntentFilter?,
+ private val appPredictionAvailable: Boolean,
) {
- private val mIsComponentAvailable =
- context.packageManager.appPredictionServicePackageName != null
-
/**
* Creates an AppPredictor instance for a profile or `null` if app predictor is not available.
*/
fun create(userHandle: UserHandle): AppPredictor? {
- if (!mIsComponentAvailable) return null
+ if (!appPredictionAvailable) return null
val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */)
- val extras = Bundle().apply {
- putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter)
- putString(SHARED_TEXT_KEY, sharedText)
- }
- val appPredictionContext = AppPredictionContext.Builder(contextAsUser)
- .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE)
- .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT)
- .setExtras(extras)
- .build()
- return contextAsUser.getSystemService(AppPredictionManager::class.java)
+ val extras =
+ Bundle().apply {
+ putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter)
+ putString(SHARED_TEXT_KEY, sharedText)
+ }
+ val appPredictionContext =
+ AppPredictionContext.Builder(contextAsUser)
+ .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE)
+ .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT)
+ .setExtras(extras)
+ .build()
+ return contextAsUser
+ .getSystemService(AppPredictionManager::class.java)
?.createAppPredictionSession(appPredictionContext)
}
}
diff --git a/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt
new file mode 100644
index 00000000..9606a6a1
--- /dev/null
+++ b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.intentresolver.shortcuts
+
+import android.app.prediction.AppPredictor
+import android.app.prediction.AppTarget
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.coroutineScope
+import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.launch
+
+/**
+ * A memory leak workaround for b/290971946. Drops the references to the actual [callback] when the
+ * [scope] is cancelled allowing it to be garbage-collected (and only leaking this instance).
+ */
+class ScopedAppTargetListCallback(
+ scope: CoroutineScope?,
+ callback: (List<AppTarget>) -> Unit,
+) {
+
+ @Volatile private var callbackRef: ((List<AppTarget>) -> Unit)? = callback
+
+ constructor(
+ context: Context,
+ callback: (List<AppTarget>) -> Unit,
+ ) : this((context as? LifecycleOwner)?.lifecycle?.coroutineScope, callback)
+
+ init {
+ scope?.launch { awaitCancellation() }?.invokeOnCompletion { callbackRef = null }
+ }
+
+ private fun notifyCallback(result: List<AppTarget>) {
+ callbackRef?.invoke(result)
+ }
+
+ fun toConsumer(): Consumer<MutableList<AppTarget>?> =
+ Consumer<MutableList<AppTarget>?> { notifyCallback(it ?: emptyList()) }
+
+ fun toAppPredictorCallback(): AppPredictor.Callback =
+ AppPredictor.Callback { notifyCallback(it) }
+}
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
index f05542e2..aa1f385f 100644
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
@@ -35,21 +35,27 @@ import androidx.annotation.MainThread
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
+import com.android.intentresolver.Flags.fixShortcutsFlashingFixed
import com.android.intentresolver.chooser.DisplayResolveInfo
import com.android.intentresolver.measurements.Tracer
import com.android.intentresolver.measurements.runTracing
import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicReference
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@@ -58,56 +64,63 @@ import kotlinx.coroutines.launch
* A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut
* updates. The shortcut loading is triggered in the constructor or by the [reset] method, the
* processing happens on the [dispatcher] and the result is delivered through the [callback] on the
- * default [lifecycle]'s dispatcher, the main thread.
+ * default [scope]'s dispatcher, the main thread.
*/
@OpenForTesting
open class ShortcutLoader
@VisibleForTesting
constructor(
private val context: Context,
- private val lifecycle: Lifecycle,
+ parentScope: CoroutineScope,
private val appPredictor: AppPredictorProxy?,
private val userHandle: UserHandle,
private val isPersonalProfile: Boolean,
private val targetIntentFilter: IntentFilter?,
private val dispatcher: CoroutineDispatcher,
- private val callback: Consumer<Result>
+ private val callback: Consumer<Result>,
) {
+ private val scope = parentScope.createChildScope()
private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter()
private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
- private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) }
+ private val appPredictorWatchdog = AtomicReference<Job?>(null)
+ private val appPredictorCallback =
+ ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback()
+
private val appTargetSource =
MutableSharedFlow<Array<DisplayResolveInfo>?>(
replay = 1,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val shortcutSource =
MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val isDestroyed
- get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
+ get() = !scope.isActive
+
+ private val id
+ get() = System.identityHashCode(this).toString(Character.MAX_RADIX)
@MainThread
constructor(
context: Context,
- lifecycle: Lifecycle,
+ scope: CoroutineScope,
appPredictor: AppPredictor?,
userHandle: UserHandle,
targetIntentFilter: IntentFilter?,
- callback: Consumer<Result>
+ callback: Consumer<Result>,
) : this(
context,
- lifecycle,
+ scope,
appPredictor?.let { AppPredictorProxy(it) },
userHandle,
userHandle == UserHandle.of(ActivityManager.getCurrentUser()),
targetIntentFilter,
Dispatchers.IO,
- callback
+ callback,
)
init {
appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback)
- lifecycle.coroutineScope
+ scope
.launch {
appTargetSource
.combine(shortcutSource) { appTargets, shortcutData ->
@@ -119,7 +132,7 @@ constructor(
appTargets,
shortcutData.shortcuts,
shortcutData.isFromAppPredictor,
- shortcutData.appPredictorTargets
+ shortcutData.appPredictorTargets,
)
}
}
@@ -130,18 +143,18 @@ constructor(
}
.invokeOnCompletion {
runCatching { appPredictor?.unregisterPredictionUpdates(appPredictorCallback) }
- Log.d(TAG, "destroyed, user: $userHandle")
+ Log.d(TAG, "[$id] destroyed, user: $userHandle")
}
reset()
}
- /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */
+ /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */
@OpenForTesting
open fun reset() {
- Log.d(TAG, "reset shortcut loader for user $userHandle")
+ Log.d(TAG, "[$id] reset shortcut loader for user $userHandle")
appTargetSource.tryEmit(null)
shortcutSource.tryEmit(null)
- lifecycle.coroutineScope.launch(dispatcher) { loadShortcuts() }
+ scope.launch(dispatcher) { loadShortcuts() }
}
/**
@@ -153,14 +166,19 @@ constructor(
appTargetSource.tryEmit(appTargets)
}
+ @OpenForTesting
+ open fun destroy() {
+ scope.cancel()
+ }
+
@WorkerThread
private fun loadShortcuts() {
// no need to query direct share for work profile when its locked or disabled
if (!shouldQueryDirectShareTargets()) {
- Log.d(TAG, "skip shortcuts loading for user $userHandle")
+ Log.d(TAG, "[$id] skip shortcuts loading for user $userHandle")
return
}
- Log.d(TAG, "querying direct share targets for user $userHandle")
+ Log.d(TAG, "[$id] querying direct share targets for user $userHandle")
queryDirectShareTargets(false)
}
@@ -168,9 +186,30 @@ constructor(
private fun queryDirectShareTargets(skipAppPredictionService: Boolean) {
if (!skipAppPredictionService && appPredictor != null) {
try {
- Log.d(TAG, "query AppPredictor for user $userHandle")
+ Log.d(TAG, "[$id] query AppPredictor for user $userHandle")
+
+ val watchdogJob =
+ if (fixShortcutsFlashingFixed()) {
+ scope
+ .launch(start = CoroutineStart.LAZY) {
+ delay(APP_PREDICTOR_RESPONSE_TIMEOUT_MS)
+ Log.w(TAG, "AppPredictor response timeout for user: $userHandle")
+ appPredictorCallback.onTargetsAvailable(emptyList())
+ }
+ .also { job ->
+ appPredictorWatchdog.getAndSet(job)?.cancel()
+ job.invokeOnCompletion {
+ appPredictorWatchdog.compareAndSet(job, null)
+ }
+ }
+ } else {
+ null
+ }
+
Tracer.beginAppPredictorQueryTrace(userHandle)
appPredictor.requestPredictionUpdate()
+
+ watchdogJob?.start()
return
} catch (e: Throwable) {
endAppPredictorQueryTrace(userHandle)
@@ -178,20 +217,25 @@ constructor(
if (isDestroyed) {
return
}
- Log.e(TAG, "Failed to query AppPredictor for user $userHandle", e)
+ Log.e(TAG, "[$id] failed to query AppPredictor for user $userHandle", e)
}
}
// Default to just querying ShortcutManager if AppPredictor not present.
if (targetIntentFilter == null) {
- Log.d(TAG, "skip querying ShortcutManager for $userHandle")
+ Log.d(TAG, "[$id] skip querying ShortcutManager for $userHandle")
+ sendShareShortcutInfoList(
+ emptyList(),
+ isFromAppPredictor = false,
+ appPredictorTargets = null,
+ )
return
}
- Log.d(TAG, "query ShortcutManager for user $userHandle")
+ Log.d(TAG, "[$id] query ShortcutManager for user $userHandle")
val shortcuts =
runTracing("shortcut-mngr-${userHandle.identifier}") {
queryShortcutManager(targetIntentFilter)
}
- Log.d(TAG, "receive shortcuts from ShortcutManager for user $userHandle")
+ Log.d(TAG, "[$id] receive shortcuts from ShortcutManager for user $userHandle")
sendShareShortcutInfoList(shortcuts, false, null)
}
@@ -203,14 +247,14 @@ constructor(
val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager
return sm?.getShareTargets(targetIntentFilter)?.filter {
pm.isPackageEnabled(it.targetComponent.packageName)
- }
- ?: emptyList()
+ } ?: emptyList()
}
@WorkerThread
private fun onAppPredictorCallback(appPredictorTargets: List<AppTarget>) {
+ appPredictorWatchdog.get()?.cancel()
endAppPredictorQueryTrace(userHandle)
- Log.d(TAG, "receive app targets from AppPredictor")
+ Log.d(TAG, "[$id] receive app targets from AppPredictor")
if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) {
// APS may be disabled, so try querying targets ourselves.
queryDirectShareTargets(true)
@@ -240,7 +284,7 @@ constructor(
private fun sendShareShortcutInfoList(
shortcuts: List<ShareShortcutInfo>,
isFromAppPredictor: Boolean,
- appPredictorTargets: List<AppTarget>?
+ appPredictorTargets: List<AppTarget>?,
) {
shortcutSource.tryEmit(ShortcutData(shortcuts, isFromAppPredictor, appPredictorTargets))
}
@@ -249,7 +293,7 @@ constructor(
appTargets: Array<DisplayResolveInfo>,
shortcuts: List<ShareShortcutInfo>,
isFromAppPredictor: Boolean,
- appPredictorTargets: List<AppTarget>?
+ appPredictorTargets: List<AppTarget>?,
): Result {
if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) {
throw RuntimeException(
@@ -276,7 +320,7 @@ constructor(
shortcuts,
appPredictorTargets,
directShareAppTargetCache,
- directShareShortcutInfoCache
+ directShareShortcutInfoCache,
)
val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets)
resultRecords.add(resultRecord)
@@ -286,7 +330,7 @@ constructor(
appTargets,
resultRecords.toTypedArray(),
directShareAppTargetCache,
- directShareShortcutInfoCache
+ directShareShortcutInfoCache,
)
}
@@ -306,7 +350,7 @@ constructor(
private class ShortcutData(
val shortcuts: List<ShareShortcutInfo>,
val isFromAppPredictor: Boolean,
- val appPredictorTargets: List<AppTarget>?
+ val appPredictorTargets: List<AppTarget>?,
)
/** Resolved shortcuts with corresponding app targets. */
@@ -320,18 +364,23 @@ constructor(
/** Shortcuts grouped by app target. */
val shortcutsByApp: Array<ShortcutResultInfo>,
val directShareAppTargetCache: Map<ChooserTarget, AppTarget>,
- val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>
+ val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>,
)
+ private fun endAppPredictorQueryTrace(userHandle: UserHandle) {
+ val duration = Tracer.endAppPredictorQueryTrace(userHandle)
+ Log.d(TAG, "[$id] AppPredictor query duration for user $userHandle: $duration ms")
+ }
+
/** Shortcuts grouped by app. */
class ShortcutResultInfo(
val appTarget: DisplayResolveInfo,
- val shortcuts: List<ChooserTarget?>
+ val shortcuts: List<ChooserTarget?>,
)
private class ShortcutsAppTargetsPair(
val shortcuts: List<ShareShortcutInfo>,
- val appTargets: List<AppTarget>?
+ val appTargets: List<AppTarget>?,
)
/** A wrapper around AppPredictor to facilitate unit-testing. */
@@ -340,7 +389,7 @@ constructor(
/** [AppPredictor.registerPredictionUpdates] */
open fun registerPredictionUpdates(
callbackExecutor: Executor,
- callback: AppPredictor.Callback
+ callback: AppPredictor.Callback,
) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback)
/** [AppPredictor.unregisterPredictionUpdates] */
@@ -352,6 +401,7 @@ constructor(
}
companion object {
+ @VisibleForTesting const val APP_PREDICTOR_RESPONSE_TIMEOUT_MS = 2_000L
private const val TAG = "ShortcutLoader"
private fun PackageManager.isPackageEnabled(packageName: String): Boolean {
@@ -364,16 +414,19 @@ constructor(
packageName,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
- )
+ ),
)
appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0
}
.getOrDefault(false)
}
- private fun endAppPredictorQueryTrace(userHandle: UserHandle) {
- val duration = Tracer.endAppPredictorQueryTrace(userHandle)
- Log.d(TAG, "AppPredictor query duration for user $userHandle: $duration ms")
- }
+ /**
+ * Creates a new coroutine scope and makes its job a child of the given, `this`, coroutine
+ * scope's job. This ensures that the new scope will be canceled when the parent scope is
+ * canceled (but not vice versa).
+ */
+ private fun CoroutineScope.createChildScope() =
+ CoroutineScope(coroutineContext + Job(parent = coroutineContext[Job]))
}
}
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java
index a37d6558..31929948 100644
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java
@@ -16,8 +16,6 @@
package com.android.intentresolver.shortcuts;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
import android.app.prediction.AppTarget;
import android.content.Intent;
import android.content.pm.ShortcutInfo;
@@ -25,6 +23,9 @@ import android.content.pm.ShortcutManager;
import android.os.Bundle;
import android.service.chooser.ChooserTarget;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
diff --git a/java/src/com/android/intentresolver/ui/ActionTitle.java b/java/src/com/android/intentresolver/ui/ActionTitle.java
new file mode 100644
index 00000000..1cc96fa9
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/ActionTitle.java
@@ -0,0 +1,88 @@
+/*
+ * 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.intentresolver.ui;
+
+import android.content.Intent;
+import android.provider.MediaStore;
+
+import androidx.annotation.StringRes;
+
+import com.android.intentresolver.R;
+
+/**
+ * Provides a set of related resources for different use cases.
+ */
+public enum ActionTitle {
+ VIEW(Intent.ACTION_VIEW,
+ R.string.whichViewApplication,
+ R.string.whichViewApplicationNamed,
+ R.string.whichViewApplicationLabel),
+ EDIT(Intent.ACTION_EDIT,
+ R.string.whichEditApplication,
+ R.string.whichEditApplicationNamed,
+ R.string.whichEditApplicationLabel),
+ SEND(Intent.ACTION_SEND,
+ R.string.whichSendApplication,
+ R.string.whichSendApplicationNamed,
+ R.string.whichSendApplicationLabel),
+ SENDTO(Intent.ACTION_SENDTO,
+ R.string.whichSendToApplication,
+ R.string.whichSendToApplicationNamed,
+ R.string.whichSendToApplicationLabel),
+ SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE,
+ R.string.whichSendApplication,
+ R.string.whichSendApplicationNamed,
+ R.string.whichSendApplicationLabel),
+ CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE,
+ R.string.whichImageCaptureApplication,
+ R.string.whichImageCaptureApplicationNamed,
+ R.string.whichImageCaptureApplicationLabel),
+ DEFAULT(null,
+ R.string.whichApplication,
+ R.string.whichApplicationNamed,
+ R.string.whichApplicationLabel),
+ HOME(Intent.ACTION_MAIN,
+ R.string.whichHomeApplication,
+ R.string.whichHomeApplicationNamed,
+ R.string.whichHomeApplicationLabel);
+
+ // titles for layout that deals with http(s) intents
+ public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith;
+ public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith;
+ public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp;
+ public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp;
+
+ public final String action;
+ public final int titleRes;
+ public final int namedTitleRes;
+ public final @StringRes int labelRes;
+
+ ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) {
+ this.action = action;
+ this.titleRes = titleRes;
+ this.namedTitleRes = namedTitleRes;
+ this.labelRes = labelRes;
+ }
+
+ public static ActionTitle forAction(String action) {
+ for (ActionTitle title : values()) {
+ if (title != HOME && action != null && action.equals(title.action)) {
+ return title;
+ }
+ }
+ return DEFAULT;
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt
new file mode 100644
index 00000000..0d07af8f
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui
+
+import android.content.res.Resources
+import com.android.intentresolver.R
+import com.android.intentresolver.data.repository.DevicePolicyResources
+import com.android.intentresolver.inject.ApplicationOwned
+import com.android.intentresolver.shared.model.Profile
+import javax.inject.Inject
+
+class ProfilePagerResources
+@Inject
+constructor(
+ @ApplicationOwned private val resources: Resources,
+ private val devicePolicyResources: DevicePolicyResources
+) {
+ private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) }
+
+ private val privateTabAccessibilityLabel by lazy {
+ resources.getString(R.string.resolver_private_tab_accessibility)
+ }
+
+ fun profileTabLabel(profile: Profile.Type): String {
+ return when (profile) {
+ Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel
+ Profile.Type.WORK -> devicePolicyResources.workTabLabel
+ Profile.Type.PRIVATE -> privateTabLabel
+ }
+ }
+
+ fun profileTabAccessibilityLabel(type: Profile.Type): String {
+ return when (type) {
+ Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel
+ Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel
+ Profile.Type.PRIVATE -> privateTabAccessibilityLabel
+ }
+ }
+
+ fun noAppsMessage(type: Profile.Type): String {
+ return when (type) {
+ Profile.Type.PERSONAL -> devicePolicyResources.noPersonalApps
+ Profile.Type.WORK -> devicePolicyResources.noWorkApps
+ Profile.Type.PRIVATE -> resources.getString(R.string.resolver_no_private_apps_available)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt
new file mode 100644
index 00000000..2684b817
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui
+
+import android.app.Activity
+import android.app.compat.CompatChanges
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.service.chooser.ChooserResult
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN
+import android.service.chooser.ChooserResult.ResultType
+import android.util.Log
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.Main
+import com.android.intentresolver.ui.model.ShareAction
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ActivityContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private const val TAG = "ShareResultSender"
+
+/** Reports the result of a share to another process across binder, via an [IntentSender] */
+interface ShareResultSender {
+ /** Reports user selection of an activity to launch from the provided choices. */
+ fun onComponentSelected(component: ComponentName, directShare: Boolean, crossProfile: Boolean)
+
+ /** Reports user invocation of a built-in system action. See [ShareAction]. */
+ fun onActionSelected(action: ShareAction)
+}
+
+@AssistedFactory
+interface ShareResultSenderFactory {
+ fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl
+}
+
+/** Dispatches Intents via IntentSender */
+fun interface IntentSenderDispatcher {
+ fun dispatchIntent(intentSender: IntentSender, intent: Intent)
+}
+
+class ShareResultSenderImpl(
+ @Main private val scope: CoroutineScope,
+ @Background val backgroundDispatcher: CoroutineDispatcher,
+ private val callerUid: Int,
+ private val resultSender: IntentSender,
+ private val intentDispatcher: IntentSenderDispatcher
+) : ShareResultSender {
+ @AssistedInject
+ constructor(
+ @ActivityContext context: Context,
+ @Main scope: CoroutineScope,
+ @Background backgroundDispatcher: CoroutineDispatcher,
+ @Assisted callerUid: Int,
+ @Assisted chosenComponentSender: IntentSender,
+ ) : this(
+ scope,
+ backgroundDispatcher,
+ callerUid,
+ chosenComponentSender,
+ IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) }
+ )
+
+ override fun onComponentSelected(
+ component: ComponentName,
+ directShare: Boolean,
+ crossProfile: Boolean
+ ) {
+ Log.i(TAG, "onComponentSelected: $component directShare=$directShare cross=$crossProfile")
+ scope.launch {
+ val intent = createChosenComponentIntent(component, directShare, crossProfile)
+ intent?.let { intentDispatcher.dispatchIntent(resultSender, it) }
+ }
+ }
+
+ override fun onActionSelected(action: ShareAction) {
+ Log.i(TAG, "onActionSelected: $action")
+ scope.launch {
+ if (chooserResultSupported(callerUid)) {
+ @ResultType val chosenAction = shareActionToChooserResult(action)
+ val intent: Intent = createSelectedActionIntent(chosenAction)
+ intentDispatcher.dispatchIntent(resultSender, intent)
+ } else {
+ Log.i(TAG, "Not sending SelectedAction")
+ }
+ }
+ }
+
+ private suspend fun createChosenComponentIntent(
+ component: ComponentName,
+ direct: Boolean,
+ crossProfile: Boolean,
+ ): Intent? {
+ if (chooserResultSupported(callerUid)) {
+ if (crossProfile) {
+ Log.i(TAG, "Redacting package from cross-profile ${Intent.EXTRA_CHOOSER_RESULT}")
+ return Intent()
+ .putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_UNKNOWN, null, direct)
+ )
+ } else {
+ // Add extra with component name for backwards compatibility.
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+
+ // Add ChooserResult value for Android V+
+ intent.putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct)
+ )
+ return intent
+ }
+ } else {
+ if (crossProfile) {
+ // We can only send cross-profile results in the new ChooserResult format.
+ Log.i(TAG, "Omitting selection callback for cross-profile target")
+ return null
+ } else {
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+ Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}")
+ return intent
+ }
+ }
+ }
+
+ @ResultType
+ private fun shareActionToChooserResult(action: ShareAction) =
+ when (action) {
+ ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY
+ ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT
+ ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN
+ }
+
+ private fun createSelectedActionIntent(@ResultType result: Int): Intent {
+ return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false))
+ }
+
+ private suspend fun chooserResultSupported(uid: Int): Boolean {
+ return withContext(backgroundDispatcher) {
+ // background -> Binder call to system_server
+ CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid)
+ }
+ }
+}
+
+private fun IntentSender.dispatchIntent(context: Context, intent: Intent) {
+ try {
+ sendIntent(
+ /* context = */ context,
+ /* code = */ Activity.RESULT_OK,
+ /* intent = */ intent,
+ /* onFinished = */ null,
+ /* handler = */ null
+ )
+ } catch (e: IntentSender.SendIntentException) {
+ Log.e(TAG, "Failed to send intent to IntentSender", e)
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt
new file mode 100644
index 00000000..7239198e
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui
+
+import android.content.res.Resources
+import android.provider.DeviceConfig
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AppShortcutLimit
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class EnforceShortcutLimit
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ShortcutRowLimit
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ShortcutPolicyModule {
+ /**
+ * Defines the limit for the number of shortcut targets provided for any single app.
+ *
+ * This value applies to both results from Shortcut-service and app-provided targets on a
+ * per-package basis.
+ */
+ @Provides
+ @Singleton
+ @AppShortcutLimit
+ fun appShortcutLimit(@ApplicationOwned resources: Resources): Int {
+ return resources.getInteger(R.integer.config_maxShortcutTargetsPerApp)
+ }
+
+ /**
+ * Once this value is no longer necessary it should be replaced in tests with simply replacing
+ * [AppShortcutLimit]:
+ * ```
+ * @BindValue
+ * @AppShortcutLimit
+ * var shortcutLimit = Int.MAX_VALUE
+ * ```
+ */
+ @Provides
+ @Singleton
+ @EnforceShortcutLimit
+ fun applyShortcutLimit(): Boolean {
+ return DeviceConfig.getBoolean(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
+ true
+ )
+ }
+
+ /**
+ * Defines the limit for the number of shortcuts presented within the direct share row.
+ *
+ * This value applies to all displayed direct share targets, including those from Shortcut
+ * service as well as app-provided targets.
+ */
+ @Provides
+ @Singleton
+ @ShortcutRowLimit
+ fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int {
+ return resources.getInteger(R.integer.config_chooser_max_targets_per_row)
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt
new file mode 100644
index 00000000..363c413d
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.model
+
+import android.content.Intent
+import android.content.pm.ResolveInfo
+import android.os.UserHandle
+import com.android.intentresolver.ext.isHomeIntent
+import com.android.intentresolver.shared.model.Profile
+
+/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */
+data class ResolverRequest(
+ /** The intent to be resolved to a target. */
+ val intent: Intent,
+
+ /**
+ * Supplied by the system to indicate which profile should be selected by default. This is
+ * required since ResolverActivity may be launched as either the originating OR target user when
+ * resolving a cross profile intent.
+ *
+ * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null
+ * when the intent is not a forwarded cross-profile intent.
+ */
+ val selectedProfile: Profile.Type?,
+
+ /**
+ * When handing a cross profile forwarded intent, this is the user which started the original
+ * intent. This is required to allow ResolverActivity to be launched as the target user under
+ * some conditions.
+ */
+ val callingUser: UserHandle?,
+
+ /**
+ * Indicates if resolving actions for a connected device which has audio capture capability
+ * (e.g. is a USB Microphone).
+ *
+ * When used to handle a connected device, ResolverActivity uses this signal to present a
+ * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected
+ * the app would be able to capture audio directly via the device, bypassing audio API
+ * permissions.)
+ */
+ val isAudioCaptureDevice: Boolean = false,
+
+ /** A list of a resolved activity targets. This list overrides normal intent resolution. */
+ val resolutionList: List<ResolveInfo>? = null,
+
+ /** A customized title for the resolver interface. */
+ val title: String? = null,
+) {
+ val isResolvingHome = intent.isHomeIntent()
+
+ /** For compatibility with existing code shared between chooser/resolver. */
+ val payloadIntents: List<Intent> = listOf(intent)
+}
diff --git a/java/src/com/android/intentresolver/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/ui/model/ShareAction.kt
new file mode 100644
index 00000000..4d727b9a
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/model/ShareAction.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.model
+
+enum class ShareAction {
+ SYSTEM_COPY,
+ SYSTEM_EDIT,
+ APPLICATION_DEFINED
+}
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
new file mode 100644
index 00000000..cb4bdcc1
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI
+import android.content.Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT
+import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INITIAL_INTENTS
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_METADATA_TEXT
+import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS
+import android.content.Intent.EXTRA_TEXT
+import android.content.Intent.EXTRA_TITLE
+import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserSession
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.ChooserActivity
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.Flags.interactiveSession
+import com.android.intentresolver.R
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.ext.hasSendAction
+import com.android.intentresolver.ext.ifMatch
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.util.hasValidIcon
+import com.android.intentresolver.validation.Validation
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.types.IntentOrUri
+import com.android.intentresolver.validation.types.array
+import com.android.intentresolver.validation.types.value
+import com.android.intentresolver.validation.validateFrom
+
+private const val MAX_CHOOSER_ACTIONS = 5
+private const val MAX_INITIAL_INTENTS = 2
+private const val EXTRA_CHOOSER_INTERACTIVE_CALLBACK =
+ "com.android.extra.EXTRA_CHOOSER_INTERACTIVE_CALLBACK"
+
+internal fun Intent.maybeAddSendActionFlags() =
+ ifMatch(Intent::hasSendAction) {
+ addFlags(FLAG_ACTIVITY_NEW_DOCUMENT)
+ addFlags(FLAG_ACTIVITY_MULTIPLE_TASK)
+ }
+
+fun readChooserRequest(
+ model: ActivityModel,
+ savedState: Bundle = model.intent.extras ?: Bundle(),
+): ValidationResult<ChooserRequest> {
+ return readChooserRequest(savedState, model.launchedFromPackage, model.referrer)
+}
+
+fun readChooserRequest(
+ savedState: Bundle,
+ launchedFromPackage: String,
+ referrer: Uri?,
+): ValidationResult<ChooserRequest> {
+ @Suppress("DEPRECATION")
+ return validateFrom(savedState::get) {
+ val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags()
+
+ val isSendAction = targetIntent.hasSendAction()
+
+ val additionalTargets = readAlternateIntents() ?: emptyList()
+
+ val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS))
+
+ val (customTitle, defaultTitleResource) =
+ if (isSendAction) {
+ ignored(
+ value<CharSequence>(EXTRA_TITLE),
+ "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " +
+ "property of the wrapped EXTRA_INTENT.",
+ )
+ null to R.string.chooseActivity
+ } else {
+ val custom = optional(value<CharSequence>(EXTRA_TITLE))
+ custom to (custom?.let { 0 } ?: R.string.chooseActivity)
+ }
+
+ val initialIntents =
+ optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map {
+ it.maybeAddSendActionFlags()
+ } ?: emptyList()
+
+ val chosenComponentSender =
+ optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER))
+ ?: optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER))
+
+ val refinementIntentSender =
+ optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER))
+
+ val filteredComponents =
+ optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList()
+
+ @Suppress("DEPRECATION")
+ val callerChooserTargets =
+ optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList()
+
+ val retainInOnStop =
+ optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false
+
+ val sharedTextTitle = targetIntent.getCharSequenceExtra(EXTRA_TITLE)
+ val sharedText = targetIntent.getCharSequenceExtra(EXTRA_TEXT)
+
+ val chooserActions = readChooserActions() ?: emptyList()
+
+ val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION))
+
+ val additionalContentUri: Uri?
+ val focusedItemPos: Int
+ if (isSendAction) {
+ additionalContentUri = optional(value<Uri>(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI))
+ focusedItemPos = optional(value<Int>(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0
+ } else {
+ additionalContentUri = null
+ focusedItemPos = 0
+ }
+
+ val contentTypeHint =
+ when (optional(value<Int>(EXTRA_CHOOSER_CONTENT_TYPE_HINT))) {
+ Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM
+ else -> ContentTypeHint.NONE
+ }
+
+ val metadataText = optional(value<CharSequence>(EXTRA_METADATA_TEXT))
+
+ val interactiveSessionCallback =
+ if (interactiveSession()) {
+ optional(value<ChooserSession>(EXTRA_CHOOSER_INTERACTIVE_CALLBACK))
+ ?.sessionCallbackBinder
+ } else {
+ null
+ }
+
+ ChooserRequest(
+ targetIntent = targetIntent,
+ targetAction = targetIntent.action,
+ isSendActionTarget = isSendAction,
+ targetType = targetIntent.type,
+ launchedFromPackage =
+ requireNotNull(launchedFromPackage) {
+ "launch.fromPackage was null, See Activity.getLaunchedFromPackage()"
+ },
+ title = customTitle,
+ defaultTitleResource = defaultTitleResource,
+ referrer = referrer,
+ filteredComponentNames = filteredComponents,
+ callerChooserTargets = callerChooserTargets,
+ chooserActions = chooserActions,
+ modifyShareAction = modifyShareAction,
+ shouldRetainInOnStop = retainInOnStop,
+ additionalTargets = additionalTargets,
+ replacementExtras = replacementExtras,
+ initialIntents = initialIntents,
+ chosenComponentSender = chosenComponentSender,
+ refinementIntentSender = refinementIntentSender,
+ sharedText = sharedText,
+ sharedTextTitle = sharedTextTitle,
+ shareTargetFilter = targetIntent.createIntentFilter(),
+ additionalContentUri = additionalContentUri,
+ focusedItemPosition = focusedItemPos,
+ contentTypeHint = contentTypeHint,
+ metadataText = metadataText,
+ interactiveSessionCallback = interactiveSessionCallback,
+ )
+ }
+}
+
+fun Validation.readAlternateIntents(): List<Intent>? =
+ optional(array<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() }
+
+fun Validation.readChooserActions(): List<ChooserAction>? =
+ optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS))
+ ?.filter { hasValidIcon(it) }
+ ?.take(MAX_CHOOSER_ACTIONS)
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt
new file mode 100644
index 00000000..7bc811c0
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.ContentInterface
+import android.os.Bundle
+import android.util.Log
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.intentresolver.Flags.interactiveSession
+import com.android.intentresolver.Flags.saveShareouselState
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.PreviewDataProvider
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.ActivityModelRepository
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import com.android.intentresolver.domain.saveUpdates
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.interactive.domain.interactor.InteractiveSessionInteractor
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import dagger.Lazy
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+
+private const val TAG = "ChooserViewModel"
+const val CHOOSER_REQUEST_KEY = "chooser-request"
+
+@HiltViewModel
+class ChooserViewModel
+@Inject
+constructor(
+ savedStateHandle: SavedStateHandle,
+ activityModelRepository: ActivityModelRepository,
+ private val shareouselViewModelProvider: Lazy<ShareouselViewModel>,
+ private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>,
+ private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ /**
+ * Provided only for the express purpose of early exit in the event of an invalid request.
+ *
+ * Note: [request] can only be safely accessed after checking if this value is [Valid].
+ */
+ val initialRequest: ValidationResult<ChooserRequest>,
+ private val chooserRequestRepository: Lazy<ChooserRequestRepository>,
+ private val contentResolver: ContentInterface,
+ val imageLoader: ImageLoader,
+ private val interactiveSessionInteractorLazy: Lazy<InteractiveSessionInteractor>,
+) : ViewModel() {
+
+ /** Parcelable-only references provided from the creating Activity */
+ val activityModel: ActivityModel = activityModelRepository.value
+
+ val shareouselViewModel: ShareouselViewModel by lazy {
+ // TODO: consolidate this logic, this would require a consolidated preview view model but
+ // for now just postpone starting the payload selection preview machinery until it's needed
+ viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() }
+ viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() }
+ shareouselViewModelProvider.get()
+ }
+
+ /**
+ * A [StateFlow] of [ChooserRequest].
+ *
+ * Note: Only safe to access after checking if [initialRequest] is [Valid].
+ */
+ val request: StateFlow<ChooserRequest>
+ get() = chooserRequestRepository.get().chooserRequest.asStateFlow()
+
+ val previewDataProvider by lazy {
+ val chooserRequest = (initialRequest as Valid<ChooserRequest>).value
+ PreviewDataProvider(
+ viewModelScope + bgDispatcher,
+ chooserRequest.targetIntent,
+ chooserRequest.additionalContentUri,
+ contentResolver,
+ )
+ }
+
+ val interactiveSessionInteractor: InteractiveSessionInteractor
+ get() = interactiveSessionInteractorLazy.get()
+
+ init {
+ when (initialRequest) {
+ is Invalid -> {
+ Log.w(TAG, "initialRequest is Invalid, initialization failed")
+ }
+ is Valid<ChooserRequest> -> {
+ if (saveShareouselState()) {
+ val isRestored =
+ savedStateHandle.get<Bundle>(CHOOSER_REQUEST_KEY)?.takeIf { !it.isEmpty } !=
+ null
+ savedStateHandle.setSavedStateProvider(CHOOSER_REQUEST_KEY) {
+ Bundle().also { result ->
+ request.value
+ .takeIf { isRestored || it != initialRequest.value }
+ ?.saveUpdates(result)
+ }
+ }
+ }
+ if (interactiveSession()) {
+ viewModelScope.launch(bgDispatcher) { interactiveSessionInteractor.activate() }
+ }
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt b/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt
new file mode 100644
index 00000000..30f16d20
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.IntentFilter.MalformedMimeTypeException
+import android.net.Uri
+import android.os.PatternMatcher
+
+/** Collects Uris from standard locations within the Intent. */
+fun Intent.collectUris(): Set<Uri> = buildSet {
+ data?.also { add(it) }
+ @Suppress("DEPRECATION")
+ when (val stream = extras?.get(Intent.EXTRA_STREAM)) {
+ is Uri -> add(stream)
+ is ArrayList<*> -> addAll(stream.mapNotNull { it as? Uri })
+ else -> Unit
+ }
+ clipData?.apply { (0..<itemCount).mapNotNull { getItemAt(it).uri }.forEach(::add) }
+}
+
+fun IntentFilter.addUri(uri: Uri) {
+ uri.scheme?.also { addDataScheme(it) }
+ uri.host?.also { addDataAuthority(it, null) }
+ uri.path?.also { addDataPath(it, PatternMatcher.PATTERN_LITERAL) }
+}
+
+fun Intent.createIntentFilter(): IntentFilter? {
+ val uris = collectUris()
+ if (action == null && uris.isEmpty()) {
+ // at least one is required to be meaningful
+ return null
+ }
+ return IntentFilter().also { filter ->
+ type?.also {
+ try {
+ filter.addDataType(it)
+ } catch (_: MalformedMimeTypeException) { // ignore malformed type
+ }
+ }
+ action?.also { filter.addAction(it) }
+ uris.forEach(filter::addUri)
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt
new file mode 100644
index 00000000..884be635
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.os.Bundle
+import android.os.UserHandle
+import com.android.intentresolver.ResolverActivity.PROFILE_PERSONAL
+import com.android.intentresolver.ResolverActivity.PROFILE_WORK
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.ui.model.ResolverRequest
+import com.android.intentresolver.validation.Validation
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.types.value
+import com.android.intentresolver.validation.validateFrom
+
+const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"
+const val EXTRA_SELECTED_PROFILE =
+ "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"
+const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"
+
+fun readResolverRequest(launch: ActivityModel): ValidationResult<ResolverRequest> {
+ @Suppress("DEPRECATION")
+ return validateFrom((launch.intent.extras ?: Bundle())::get) {
+ val callingUser = optional(value<UserHandle>(EXTRA_CALLING_USER))
+ val selectedProfile = checkSelectedProfile()
+ val audioDevice = optional(value<Boolean>(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false
+ ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice)
+ }
+}
+
+private fun Validation.checkSelectedProfile(): Profile.Type? {
+ return when (val selected = optional(value<Int>(EXTRA_SELECTED_PROFILE))) {
+ null -> null
+ PROFILE_PERSONAL -> Profile.Type.PERSONAL
+ PROFILE_WORK -> Profile.Type.WORK
+ else ->
+ error(
+ EXTRA_SELECTED_PROFILE +
+ " has invalid value ($selected)." +
+ " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" +
+ " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)."
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt
new file mode 100644
index 00000000..3511637b
--- /dev/null
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.android.intentresolver.data.repository.ActivityModelRepository
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.ui.model.ResolverRequest
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.Valid
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+private const val TAG = "ResolverViewModel"
+
+@HiltViewModel
+class ResolverViewModel @Inject constructor(activityModelrepo: ActivityModelRepository) :
+ ViewModel() {
+
+ /** Parcelable-only references provided from the creating Activity */
+ val activityModel: ActivityModel = activityModelrepo.value
+
+ /**
+ * Provided only for the express purpose of early exit in the event of an invalid request.
+ *
+ * Note: [request] can only be safely accessed after checking if this value is [Valid].
+ */
+ internal val initialRequest = readResolverRequest(activityModel)
+
+ private lateinit var _request: MutableStateFlow<ResolverRequest>
+
+ /**
+ * A [StateFlow] of [ResolverRequest].
+ *
+ * Note: Only safe to access after checking if [initialRequest] is [Valid].
+ */
+ lateinit var request: StateFlow<ResolverRequest>
+ private set
+
+ init {
+ when (initialRequest) {
+ is Valid -> {
+ _request = MutableStateFlow(initialRequest.value)
+ request = _request.asStateFlow()
+ }
+ is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed")
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt
new file mode 100644
index 00000000..e89cb5ca
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
+
+import android.os.CancellationSignal
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Invokes [block] with a [CancellationSignal] that is bound to this coroutine's lifetime; if this
+ * coroutine is cancelled, then [CancellationSignal.cancel] is promptly invoked.
+ */
+suspend fun <R> withCancellationSignal(block: suspend (signal: CancellationSignal) -> R): R =
+ coroutineScope {
+ val signal = CancellationSignal()
+ val signalJob =
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ awaitCancellation()
+ } finally {
+ signal.cancel()
+ }
+ }
+ block(signal).also { signalJob.cancel() }
+ }
diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt
index 1155b9fe..598379f3 100644
--- a/java/src/com/android/intentresolver/util/Flow.kt
+++ b/java/src/com/android/intentresolver/util/Flow.kt
@@ -31,7 +31,6 @@ import kotlinx.coroutines.launch
* latest value is emitted.
*
* Example:
- *
* ```kotlin
* flow {
* emit(1) // t=0ms
@@ -70,10 +69,11 @@ fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = channelFlow {
// We create delayJob to allow cancellation during the delay period
delayJob = launch {
delay(timeUntilNextEmit)
- sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) {
- send(it)
- previousEmitTimeMs = SystemClock.elapsedRealtime()
- }
+ sendJob =
+ outerScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ send(it)
+ previousEmitTimeMs = SystemClock.elapsedRealtime()
+ }
}
} else {
send(it)
diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt
new file mode 100644
index 00000000..745bcdbf
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.yield
+
+/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */
+suspend fun <A, B> Iterable<A>.mapParallel(
+ parallelism: Int? = null,
+ block: suspend (A) -> B,
+): List<B> =
+ parallelism?.let { permits ->
+ withSemaphore(permits = permits) { mapParallel { withPermit { block(it) } } }
+ }
+ ?: mapParallel(block)
+
+/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */
+suspend fun <A, B> Sequence<A>.mapParallel(
+ parallelism: Int? = null,
+ block: suspend (A) -> B,
+): List<B> = asIterable().mapParallel(parallelism, block)
+
+private suspend fun <A, B> Iterable<A>.mapParallel(block: suspend (A) -> B): List<B> =
+ coroutineScope {
+ map {
+ async {
+ yield()
+ block(it)
+ }
+ }
+ .awaitAll()
+ }
+
+suspend fun <A, B> Iterable<A>.mapParallelIndexed(
+ parallelism: Int? = null,
+ block: suspend (Int, A) -> B,
+): List<B> =
+ parallelism?.let { permits ->
+ withSemaphore(permits = permits) {
+ mapParallelIndexed { idx, item -> withPermit { block(idx, item) } }
+ }
+ } ?: mapParallelIndexed(block)
+
+private suspend fun <A, B> Iterable<A>.mapParallelIndexed(block: suspend (Int, A) -> B): List<B> =
+ coroutineScope {
+ mapIndexed { index, item ->
+ async {
+ yield()
+ block(index, item)
+ }
+ }
+ .awaitAll()
+ }
diff --git a/java/src/com/android/intentresolver/util/SyncUtils.kt b/java/src/com/android/intentresolver/util/SyncUtils.kt
new file mode 100644
index 00000000..eaebc6ea
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/SyncUtils.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.Semaphore
+
+/**
+ * Constructs a [Semaphore] for usage within [block], useful for launching a lot of work in parallel
+ * that needs some synchronization.
+ */
+inline fun <R> withSemaphore(permits: Int, block: Semaphore.() -> R): R =
+ Semaphore(permits).run(block)
+
+/**
+ * Constructs a [Mutex] for usage within [block], useful for launching a lot of work in parallel
+ * that needs some synchronization.
+ */
+inline fun <R> withMutex(block: Mutex.() -> R): R = Mutex().run(block)
diff --git a/java/src/com/android/intentresolver/util/cursor/CursorView.kt b/java/src/com/android/intentresolver/util/cursor/CursorView.kt
new file mode 100644
index 00000000..eca7d335
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/cursor/CursorView.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util.cursor
+
+import android.database.Cursor
+
+/** A [Cursor] that holds values of [E] for each row. */
+interface CursorView<out E> : Cursor {
+ /**
+ * Reads the current row from this [CursorView]. A result of `null` indicates that the row could
+ * not be read / value could not be produced.
+ */
+ fun readRow(): E?
+}
+
+/**
+ * Returns a [CursorView] from the given [Cursor], and a function [readRow] used to produce the
+ * value for a single row.
+ */
+fun <E> Cursor.viewBy(readRow: Cursor.() -> E): CursorView<E> =
+ object : CursorView<E>, Cursor by this@viewBy {
+ override fun readRow(): E? = immobilized().readRow()
+ }
+
+/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */
+fun <E> CursorView<E>.startAt(newStartIndex: Int): CursorView<E> =
+ object : CursorView<E>, Cursor by (this@startAt as Cursor).startAt(newStartIndex) {
+ override fun readRow(): E? = this@startAt.readRow()
+ }
+
+/** Returns a [CursorView] that is truncated to contain only [count] elements. */
+fun <E> CursorView<E>.limit(count: Int): CursorView<E> =
+ object : CursorView<E>, Cursor by (this@limit as Cursor).limit(count) {
+ override fun readRow(): E? = this@limit.readRow()
+ }
+
+/** Retrieves a single row at index [idx] from the [CursorView]. */
+operator fun <E> CursorView<E>.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null
+
+/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */
+fun <E> CursorView<E>.asSequence(): Sequence<E?> = sequence {
+ for (i in 0 until count) {
+ yield(get(i))
+ }
+}
diff --git a/java/src/com/android/intentresolver/util/cursor/Cursors.kt b/java/src/com/android/intentresolver/util/cursor/Cursors.kt
new file mode 100644
index 00000000..ce768f3b
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/cursor/Cursors.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util.cursor
+
+import android.database.Cursor
+import android.database.CursorWrapper
+
+/** Returns a Cursor that is truncated to contain only [count] elements. */
+fun Cursor.limit(count: Int): Cursor =
+ object : CursorWrapper(this) {
+ override fun getCount(): Int = minOf(count, super.getCount())
+
+ override fun getPosition(): Int = super.getPosition().coerceAtMost(count)
+
+ override fun moveToLast(): Boolean = super.moveToPosition(getCount() - 1)
+
+ override fun isFirst(): Boolean = getCount() != 0 && super.isFirst()
+
+ override fun isLast(): Boolean = getCount() != 0 && super.getPosition() == getCount() - 1
+
+ override fun isAfterLast(): Boolean = getCount() == 0 || super.getPosition() >= getCount()
+
+ override fun isBeforeFirst(): Boolean = getCount() == 0 || super.isBeforeFirst()
+
+ override fun moveToNext(): Boolean = super.moveToNext() && position < getCount()
+
+ override fun moveToPosition(position: Int): Boolean =
+ super.moveToPosition(position) && position < getCount()
+ }
+
+/** Returns a Cursor that begins (index 0) at [newStartIndex] of the given Cursor. */
+fun Cursor.startAt(newStartIndex: Int): Cursor =
+ object : CursorWrapper(this) {
+ override fun getCount(): Int = (super.getCount() - newStartIndex).coerceAtLeast(0)
+
+ override fun getPosition(): Int = (super.getPosition() - newStartIndex).coerceAtLeast(-1)
+
+ override fun moveToFirst(): Boolean = super.moveToPosition(newStartIndex)
+
+ override fun moveToNext(): Boolean = super.moveToNext() && position < count
+
+ override fun moveToPrevious(): Boolean = super.moveToPrevious() && position >= 0
+
+ override fun moveToPosition(position: Int): Boolean =
+ super.moveToPosition(position + newStartIndex) && position >= 0
+
+ override fun isFirst(): Boolean = count != 0 && super.getPosition() == newStartIndex
+
+ override fun isLast(): Boolean = count != 0 && super.isLast()
+
+ override fun isBeforeFirst(): Boolean = count == 0 || super.getPosition() < newStartIndex
+
+ override fun isAfterLast(): Boolean = count == 0 || super.isAfterLast()
+ }
+
+/** Returns a read-only non-movable view into the given Cursor. */
+fun Cursor.immobilized(): Cursor =
+ object : CursorWrapper(this) {
+ private val unsupported: Nothing
+ get() = error("unsupported")
+
+ override fun moveToFirst(): Boolean = unsupported
+
+ override fun moveToLast(): Boolean = unsupported
+
+ override fun move(offset: Int): Boolean = unsupported
+
+ override fun moveToPosition(position: Int): Boolean = unsupported
+
+ override fun moveToNext(): Boolean = unsupported
+
+ override fun moveToPrevious(): Boolean = unsupported
+ }
diff --git a/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt
new file mode 100644
index 00000000..6e4318dc
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util.cursor
+
+import android.database.Cursor
+
+/** A [CursorView] that produces chunks/pages from an underlying cursor. */
+interface PagedCursor<out E> : CursorView<Sequence<E?>> {
+ /** The configured size of each page produced by this cursor. */
+ val pageSize: Int
+}
+
+/** Returns a [PagedCursor] that produces pages of data from the given [CursorView]. */
+fun <E> CursorView<E>.paged(pageSize: Int): PagedCursor<E> =
+ object : PagedCursor<E>, Cursor by this@paged {
+
+ init {
+ check(pageSize > 0) { "pageSize must be greater than 0" }
+ }
+
+ override val pageSize: Int = pageSize
+
+ override fun getCount(): Int =
+ this@paged.count.let { it / pageSize + minOf(1, it % pageSize) }
+
+ override fun getPosition(): Int =
+ (this@paged.position / pageSize).let { if (this@paged.position < 0) it - 1 else it }
+
+ override fun moveToNext(): Boolean = moveToPosition(position + 1)
+
+ override fun moveToPrevious(): Boolean = moveToPosition(position - 1)
+
+ override fun moveToPosition(position: Int): Boolean =
+ this@paged.moveToPosition(position * pageSize)
+
+ override fun readRow(): Sequence<E?> =
+ this@paged.startAt(position * pageSize).limit(pageSize).asSequence()
+ }
diff --git a/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt
new file mode 100644
index 00000000..3e2d8e2a
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("SuspendedMatrixColorFilter")
+
+package com.android.intentresolver.util.graphics
+
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+
+val suspendedColorMatrix by lazy {
+ val grayValue = 127f
+ val scale = 0.5f // half bright
+
+ val tempBrightnessMatrix =
+ ColorMatrix().apply {
+ array.let { m ->
+ m[0] = scale
+ m[6] = scale
+ m[12] = scale
+ m[4] = grayValue
+ m[9] = grayValue
+ m[14] = grayValue
+ }
+ }
+
+ val matrix =
+ ColorMatrix().apply {
+ setSaturation(0.0f)
+ preConcat(tempBrightnessMatrix)
+ }
+ ColorMatrixColorFilter(matrix)
+}
diff --git a/java/src/com/android/intentresolver/validation/Findings.kt b/java/src/com/android/intentresolver/validation/Findings.kt
new file mode 100644
index 00000000..0d62017f
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/Findings.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.intentresolver.validation
+
+import android.util.Log
+import com.android.intentresolver.validation.Importance.CRITICAL
+import com.android.intentresolver.validation.Importance.WARNING
+import kotlin.reflect.KClass
+
+sealed interface Finding {
+ val importance: Importance
+ val message: String
+}
+
+enum class Importance {
+ CRITICAL,
+ WARNING,
+}
+
+val Finding.logcatPriority
+ get() =
+ when (importance) {
+ CRITICAL -> Log.ERROR
+ WARNING -> Log.WARN
+ }
+
+fun Finding.log(tag: String) {
+ Log.println(logcatPriority, tag, message)
+}
+
+private fun formatMessage(key: String? = null, msg: String) = buildString {
+ key?.also { append("['$key']: ") }
+ append(msg)
+}
+
+data class IgnoredValue(
+ val key: String,
+ val reason: String,
+) : Finding {
+ override val importance = WARNING
+
+ override val message: String
+ get() = formatMessage(key, "Ignored. $reason")
+}
+
+data class NoValue(
+ val key: String,
+ override val importance: Importance,
+ val allowedType: KClass<*>,
+) : Finding {
+
+ override val message: String
+ get() =
+ formatMessage(
+ key,
+ if (importance == CRITICAL) {
+ "expected value of ${allowedType.simpleName}, " + "but no value was present"
+ } else {
+ "no ${allowedType.simpleName} value present"
+ }
+ )
+}
+
+data class WrongElementType(
+ val key: String,
+ override val importance: Importance,
+ val container: KClass<*>,
+ val actualType: KClass<*>,
+ val expectedType: KClass<*>
+) : Finding {
+ override val message: String
+ get() =
+ formatMessage(
+ key,
+ "${container.simpleName} expected with elements of " +
+ "${expectedType.simpleName} " +
+ "but found ${actualType.simpleName} values instead"
+ )
+}
+
+data class ValueIsWrongType(
+ val key: String,
+ override val importance: Importance,
+ val actualType: KClass<*>,
+ val allowedTypes: List<KClass<*>>,
+) : Finding {
+
+ override val message: String
+ get() =
+ formatMessage(
+ key,
+ "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " +
+ "but was ${actualType.simpleName}"
+ )
+}
+
+data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding {
+ override val importance: Importance
+ get() = CRITICAL
+ override val message: String
+ get() =
+ formatMessage(
+ key,
+ "An unhandled exception was caught during validation: " +
+ thrown.stackTraceToString()
+ )
+}
diff --git a/java/src/com/android/intentresolver/validation/Validation.kt b/java/src/com/android/intentresolver/validation/Validation.kt
new file mode 100644
index 00000000..6ba62e57
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/Validation.kt
@@ -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.intentresolver.validation
+
+import com.android.intentresolver.validation.Importance.CRITICAL
+import com.android.intentresolver.validation.Importance.WARNING
+
+/**
+ * Provides a mechanism for validating a result from a set of properties.
+ *
+ * The results of validation are provided as [findings].
+ */
+interface Validation {
+ val findings: List<Finding>
+
+ /**
+ * Require a valid property.
+ *
+ * If [property] is not valid, this [Validation] will be immediately completed as [Invalid].
+ *
+ * @param property the required property
+ * @return a valid **T**
+ */
+ @Throws(InvalidResultError::class) fun <T> required(property: Validator<T>): T
+
+ /**
+ * Request an optional value for a property.
+ *
+ * If [property] is not valid, this [Validation] will be immediately completed as [Invalid].
+ *
+ * @param property the required property
+ * @return a valid **T**
+ */
+ fun <T> optional(property: Validator<T>): T?
+
+ /**
+ * Report a property as __ignored__.
+ *
+ * The presence of any value will report a warning citing [reason].
+ */
+ fun <T> ignored(property: Validator<T>, reason: String)
+}
+
+/** Performs validation for a specific key -> value pair. */
+interface Validator<T> {
+ val key: String
+
+ /**
+ * Performs validation on a specific value from [source].
+ *
+ * @param source a source for reading the property value. Values are intentionally untyped
+ * (Any?) to avoid upstream code from making type assertions through type inference. Types are
+ * asserted later using a [Validator].
+ * @param importance the importance of any findings
+ */
+ fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T>
+}
+
+internal class InvalidResultError internal constructor() : Error()
+
+/**
+ * Perform a number of validations on the source, assembling and returning a Result.
+ *
+ * When an exception is thrown by [validate], it is caught here. In response, a failed
+ * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception.
+ *
+ * @param validate perform validations and return a [ValidationResult]
+ */
+fun <T> validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult<T> {
+ val validation = ValidationImpl(source)
+ return runCatching { validate(validation) }
+ .fold(
+ onSuccess = { result -> Valid(result, validation.findings) },
+ onFailure = {
+ when (it) {
+ // A validator has interrupted validation. Return the findings.
+ is InvalidResultError -> Invalid(validation.findings)
+
+ // Some other exception was thrown from [validate],
+ else -> Invalid(error = UncaughtException(it))
+ }
+ }
+ )
+}
+
+private class ValidationImpl(val source: (String) -> Any?) : Validation {
+ override val findings = mutableListOf<Finding>()
+
+ override fun <T> optional(property: Validator<T>): T? = validate(property, WARNING)
+
+ override fun <T> required(property: Validator<T>): T {
+ return validate(property, CRITICAL) ?: throw InvalidResultError()
+ }
+
+ override fun <T> ignored(property: Validator<T>, reason: String) {
+ val result = property.validate(source, WARNING)
+ if (result is Valid) {
+ // Note: Any warnings about the value itself (result.findings) are ignored.
+ findings += IgnoredValue(property.key, reason)
+ }
+ }
+
+ private fun <T> validate(property: Validator<T>, importance: Importance): T? {
+ return runCatching { property.validate(source, importance) }
+ .fold(
+ onSuccess = { result ->
+ return when (result) {
+ is Valid -> {
+ findings += result.warnings
+ result.value
+ }
+ is Invalid -> {
+ findings += result.errors
+ null
+ }
+ }
+ },
+ onFailure = {
+ findings += UncaughtException(it, property.key)
+ null
+ }
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/validation/ValidationResult.kt b/java/src/com/android/intentresolver/validation/ValidationResult.kt
new file mode 100644
index 00000000..9685c70d
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/ValidationResult.kt
@@ -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.intentresolver.validation
+
+sealed interface ValidationResult<T>
+
+data class Valid<T>(val value: T, val warnings: List<Finding> = emptyList()) : ValidationResult<T> {
+ constructor(value: T, warning: Finding) : this(value, listOf(warning))
+}
+
+data class Invalid<T>(val errors: List<Finding> = emptyList()) : ValidationResult<T> {
+ constructor(error: Finding) : this(listOf(error))
+}
diff --git a/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt
new file mode 100644
index 00000000..74c48a23
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt
@@ -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.intentresolver.validation.types
+
+import android.content.Intent
+import android.net.Uri
+import com.android.intentresolver.validation.Importance
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.Validator
+import com.android.intentresolver.validation.ValueIsWrongType
+
+class IntentOrUri(override val key: String) : Validator<Intent> {
+
+ override fun validate(
+ source: (String) -> Any?,
+ importance: Importance
+ ): ValidationResult<Intent> {
+ return when (val value = source(key)) {
+ // An intent, return it.
+ is Intent -> Valid(value)
+
+ // A Uri was supplied.
+ // Unfortunately, converting Uri -> Intent requires a toString().
+ is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME))
+
+ // No value present.
+ null ->
+ when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class))
+ }
+
+ // Some other type.
+ else -> {
+ return Invalid(
+ ValueIsWrongType(
+ key,
+ importance,
+ actualType = value::class,
+ allowedTypes = listOf(Intent::class, Uri::class)
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt
new file mode 100644
index 00000000..5150ec5e
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.intentresolver.validation.types
+
+import com.android.intentresolver.validation.Importance
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.Validator
+import com.android.intentresolver.validation.ValueIsWrongType
+import com.android.intentresolver.validation.WrongElementType
+import kotlin.reflect.KClass
+import kotlin.reflect.cast
+
+class ParceledArray<T : Any>(
+ override val key: String,
+ private val elementType: KClass<T>,
+) : Validator<List<T>> {
+
+ override fun validate(
+ source: (String) -> Any?,
+ importance: Importance
+ ): ValidationResult<List<T>> {
+ return when (val value: Any? = source(key)) {
+ // No value present.
+ null ->
+ when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType))
+ }
+ // A parcel does not transfer the element type information for parcelable
+ // arrays. This leads to a restored type of Array<Parcelable>, which is
+ // incompatible with Array<T : Parcelable>.
+
+ // To handle this safely, treat as Array<*>, assert contents of the expected
+ // parcelable type, and return as a list.
+
+ is Array<*> -> {
+ val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) }
+ when (invalid) {
+ // No invalid elements, result is ok.
+ null -> Valid(value.map { elementType.cast(it) })
+
+ // At least one incorrect element type found.
+ else ->
+ Invalid(
+ WrongElementType(
+ key,
+ importance,
+ actualType = invalid::class,
+ container = Array::class,
+ expectedType = elementType
+ )
+ )
+ }
+ }
+
+ // The value is not an Array at all.
+ else ->
+ Invalid(
+ ValueIsWrongType(
+ key,
+ importance,
+ actualType = value::class,
+ allowedTypes = listOf(elementType)
+ )
+ )
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt
new file mode 100644
index 00000000..64299e11
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt
@@ -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.intentresolver.validation.types
+
+import com.android.intentresolver.validation.Importance
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValidationResult
+import com.android.intentresolver.validation.Validator
+import com.android.intentresolver.validation.ValueIsWrongType
+import kotlin.reflect.KClass
+import kotlin.reflect.cast
+
+class SimpleValue<T : Any>(
+ override val key: String,
+ private val expected: KClass<T>,
+) : Validator<T> {
+
+ override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T> {
+ val value: Any? = source(key)
+ return when {
+ // The value is present and of the expected type.
+ expected.isInstance(value) -> return Valid(expected.cast(value))
+
+ // No value is present.
+ value == null ->
+ when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, expected))
+ }
+
+ // The value is some other type.
+ else ->
+ Invalid(
+ listOf(
+ ValueIsWrongType(
+ key,
+ importance,
+ actualType = value::class,
+ allowedTypes = listOf(expected)
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/validation/types/Validators.kt b/java/src/com/android/intentresolver/validation/types/Validators.kt
new file mode 100644
index 00000000..1049f045
--- /dev/null
+++ b/java/src/com/android/intentresolver/validation/types/Validators.kt
@@ -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.intentresolver.validation.types
+
+import com.android.intentresolver.validation.Validator
+
+inline fun <reified T : Any> value(key: String): Validator<T> {
+ return SimpleValue(key, T::class)
+}
+
+inline fun <reified T : Any> array(key: String): Validator<List<T>> {
+ return ParceledArray(key, T::class)
+}
diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt
index 6764d3ae..c1f03751 100644
--- a/java/src/com/android/intentresolver/widget/ActionRow.kt
+++ b/java/src/com/android/intentresolver/widget/ActionRow.kt
@@ -22,7 +22,9 @@ import android.graphics.drawable.Drawable
interface ActionRow {
fun setActions(actions: List<Action>)
- class Action @JvmOverloads constructor(
+ class Action
+ @JvmOverloads
+ constructor(
// TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we
// get rid of them
val id: Int = ID_NULL,
diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt
new file mode 100644
index 00000000..6674d92d
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.widget
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.Gravity
+import android.widget.TextView
+
+/**
+ * A TextView that supports a badge at the end of the text. If the text, when centered in the view,
+ * leaves enough room for the badge, the badge is just displayed at the end of the view. Otherwise,
+ * the necessary amount of space for the badge is reserved and the text gets centered in the
+ * remaining free space.
+ */
+class BadgeTextView : TextView {
+ constructor(context: Context) : this(context, null)
+
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
+ constructor(
+ context: Context,
+ attrs: AttributeSet?,
+ defStyleAttr: Int
+ ) : this(context, attrs, defStyleAttr, 0)
+
+ constructor(
+ context: Context?,
+ attrs: AttributeSet?,
+ defStyleAttr: Int,
+ defStyleRes: Int
+ ) : super(context, attrs, defStyleAttr, defStyleRes) {
+ super.setGravity(Gravity.CENTER)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ private var defaultPaddingLeft = 0
+ private var defaultPaddingRight = 0
+
+ override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
+ super.setPadding(left, top, right, bottom)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
+ super.setPaddingRelative(start, top, end, bottom)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ /** Sets end-sided badge. */
+ var badgeDrawable: Drawable? = null
+ set(value) {
+ if (field !== value) {
+ field = value
+ super.setBackground(value)
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.setPadding(defaultPaddingLeft, paddingTop, defaultPaddingRight, paddingBottom)
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ val badge = badgeDrawable ?: return
+ if (badge.intrinsicWidth <= paddingEnd) return
+ var maxLineWidth = 0f
+ for (i in 0 until layout.lineCount) {
+ maxLineWidth = maxOf(maxLineWidth, layout.getLineWidth(i))
+ }
+ val sideSpace = (measuredWidth - maxLineWidth) / 2
+ if (sideSpace < badge.intrinsicWidth) {
+ super.setPaddingRelative(
+ paddingStart,
+ paddingTop,
+ paddingEnd + badge.intrinsicWidth - sideSpace.toInt(),
+ paddingBottom
+ )
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+ }
+
+ override fun setBackground(background: Drawable?) {
+ badgeDrawable = null
+ super.setBackground(background)
+ }
+
+ override fun setGravity(gravity: Int): Unit = error("Not supported")
+}
diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt
new file mode 100644
index 00000000..a9577cf5
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.view.ScrollingView
+import androidx.core.view.marginBottom
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.core.view.marginTop
+import com.android.intentresolver.Flags.keyboardNavigationFix
+
+/**
+ * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to
+ * orchestrate content preview scrolling. It expects one [LinearLayout] child with
+ * [LinearLayout.VERTICAL] orientation. If the child has more than one child, the first its child
+ * will be made scrollable (it is expected to be a content preview view).
+ */
+class ChooserNestedScrollView : NestedScrollView {
+ constructor(context: Context) : super(context)
+
+ constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
+
+ constructor(
+ context: Context,
+ attrs: AttributeSet?,
+ defStyleAttr: Int,
+ ) : super(context, attrs, defStyleAttr)
+
+ var requestChildFocusPredicate: (View?, View?) -> Boolean = DefaultChildFocusPredicate
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val content =
+ getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected")
+ require(content.orientation == LinearLayout.VERTICAL) { "VERTICAL orientation is expected" }
+ require(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
+ "Expected to have an exact width"
+ }
+
+ val lp = content.layoutParams ?: error("LayoutParams is missing")
+ val contentWidthSpec =
+ getChildMeasureSpec(
+ widthMeasureSpec,
+ paddingLeft + content.marginLeft + content.marginRight + paddingRight,
+ lp.width,
+ )
+ val contentHeightSpec =
+ getChildMeasureSpec(
+ heightMeasureSpec,
+ paddingTop + content.marginTop + content.marginBottom + paddingBottom,
+ lp.height,
+ )
+ content.measure(contentWidthSpec, contentHeightSpec)
+
+ if (content.childCount > 1) {
+ // We expect that the first child should be scrollable up
+ val child = content.getChildAt(0)
+ val height =
+ MeasureSpec.getSize(heightMeasureSpec) +
+ child.measuredHeight +
+ child.marginTop +
+ child.marginBottom
+
+ content.measure(
+ contentWidthSpec,
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)),
+ )
+ }
+ setMeasuredDimension(
+ MeasureSpec.getSize(widthMeasureSpec),
+ minOf(
+ MeasureSpec.getSize(heightMeasureSpec),
+ paddingTop +
+ content.marginTop +
+ content.measuredHeight +
+ content.marginBottom +
+ paddingBottom,
+ ),
+ )
+ }
+
+ override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
+ // let the parent scroll
+ super.onNestedPreScroll(target, dx, dy, consumed, type)
+ // scroll ourselves, if recycler has not scrolled
+ val delta = dy - consumed[1]
+ if (delta > 0 && target is ScrollingView && !target.canScrollVertically(-1)) {
+ val preScrollY = scrollY
+ scrollBy(0, delta)
+ consumed[1] += scrollY - preScrollY
+ }
+ }
+
+ override fun onRequestChildFocus(child: View?, focused: View?) {
+ if (keyboardNavigationFix()) {
+ if (requestChildFocusPredicate(child, focused)) {
+ super.onRequestChildFocus(child, focused)
+ }
+ } else {
+ super.onRequestChildFocus(child, focused)
+ }
+ }
+
+ companion object {
+ val DefaultChildFocusPredicate: (View?, View?) -> Boolean = { _, _ -> true }
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt
new file mode 100644
index 00000000..816a2b1d
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.widget
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.InputDevice.SOURCE_MOUSE
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_HOVER_ENTER
+import android.view.MotionEvent.ACTION_HOVER_MOVE
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import com.android.intentresolver.R
+
+class ChooserTargetItemView(
+ context: Context,
+ attrs: AttributeSet?,
+ defStyleAttr: Int,
+ defStyleRes: Int,
+) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
+ private val outlineRadius: Float
+ private val outlineWidth: Float
+ private val outlinePaint: Paint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
+ private val outlineInnerPaint: Paint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
+ private var iconView: ImageView? = null
+
+ constructor(context: Context) : this(context, null)
+
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
+ constructor(
+ context: Context,
+ attrs: AttributeSet?,
+ defStyleAttr: Int,
+ ) : this(context, attrs, defStyleAttr, 0)
+
+ init {
+ val a = context.obtainStyledAttributes(attrs, R.styleable.ChooserTargetItemView)
+ val defaultWidth =
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 2f,
+ context.resources.displayMetrics,
+ )
+ outlineRadius =
+ a.getDimension(R.styleable.ChooserTargetItemView_focusOutlineCornerRadius, 0f)
+ outlineWidth =
+ a.getDimension(R.styleable.ChooserTargetItemView_focusOutlineWidth, defaultWidth)
+
+ outlinePaint.strokeWidth = outlineWidth
+ outlinePaint.color =
+ a.getColor(R.styleable.ChooserTargetItemView_focusOutlineColor, Color.TRANSPARENT)
+
+ outlineInnerPaint.strokeWidth = outlineWidth
+ outlineInnerPaint.color =
+ a.getColor(R.styleable.ChooserTargetItemView_focusInnerOutlineColor, Color.TRANSPARENT)
+ a.recycle()
+ }
+
+ override fun onViewAdded(child: View) {
+ super.onViewAdded(child)
+ if (child is ImageView) {
+ iconView = child
+ }
+ }
+
+ override fun onViewRemoved(child: View?) {
+ super.onViewRemoved(child)
+ if (child === iconView) {
+ iconView = null
+ }
+ }
+
+ override fun onHoverEvent(event: MotionEvent): Boolean {
+ val iconView = iconView ?: return false
+ if (!isEnabled) return true
+ when (event.action) {
+ ACTION_HOVER_ENTER -> {
+ iconView.isHovered = true
+ }
+ MotionEvent.ACTION_HOVER_EXIT -> {
+ iconView.isHovered = false
+ }
+ }
+ return true
+ }
+
+ override fun onInterceptHoverEvent(event: MotionEvent) =
+ if (event.isFromSource(SOURCE_MOUSE)) {
+ // This is the same logic as in super.onInterceptHoverEvent (ViewGroup) minus the check
+ // that the pointer fall on the scroll bar as we need to control the hover state of the
+ // icon.
+ // We also want to intercept only MOUSE hover events as the TalkBack's Explore by Touch
+ // (including single taps) reported as a hover event.
+ event.action == ACTION_HOVER_MOVE || event.action == ACTION_HOVER_ENTER
+ } else {
+ super.onInterceptHoverEvent(event)
+ }
+
+ override fun dispatchDraw(canvas: Canvas) {
+ super.dispatchDraw(canvas)
+ if (isFocused) {
+ drawFocusInnerOutline(canvas)
+ drawFocusOutline(canvas)
+ }
+ }
+
+ private fun drawFocusInnerOutline(canvas: Canvas) {
+ val outlineOffset = outlineWidth + outlineWidth / 2
+ canvas.drawRoundRect(
+ outlineOffset,
+ outlineOffset,
+ maxOf(0f, width - outlineOffset),
+ maxOf(0f, height - outlineOffset),
+ outlineRadius - outlineWidth,
+ outlineRadius - outlineWidth,
+ outlineInnerPaint,
+ )
+ }
+
+ private fun drawFocusOutline(canvas: Canvas) {
+ val outlineOffset = outlineWidth / 2
+ canvas.drawRoundRect(
+ outlineOffset,
+ outlineOffset,
+ maxOf(0f, width - outlineOffset),
+ maxOf(0f, height - outlineOffset),
+ outlineRadius,
+ outlineRadius,
+ outlinePaint,
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
index 3f0458ee..55418c49 100644
--- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
@@ -24,15 +24,16 @@ interface ImagePreviewView {
/**
* [ImagePreviewView] progressively prepares views for shared element transition and reports
- * each successful preparation with [onTransitionElementReady] call followed by
- * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is
- * zero or more [onTransitionElementReady] calls followed by the final
- * [onAllTransitionElementsReady] call.
+ * each successful preparation with [onTransitionElementReady] call followed by closing
+ * [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is zero or
+ * more [onTransitionElementReady] calls followed by the final [onAllTransitionElementsReady]
+ * call.
*/
interface TransitionElementStatusCallback {
/**
- * Invoked when a view for a shared transition animation element is ready i.e. the image
- * is loaded and the view is laid out.
+ * Invoked when a view for a shared transition animation element is ready i.e. the image is
+ * loaded and the view is laid out.
+ *
* @param name shared element name.
*/
fun onTransitionElementReady(name: String)
diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java b/java/src/com/android/intentresolver/widget/NestedScrollView.java
new file mode 100644
index 00000000..36fc7da6
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java
@@ -0,0 +1,2611 @@
+/*
+ * Copyright 2024 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.intentresolver.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.FocusFinder;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.animation.AnimationUtils;
+import android.widget.EdgeEffect;
+import android.widget.FrameLayout;
+import android.widget.OverScroller;
+import android.widget.ScrollView;
+
+import androidx.annotation.DoNotInline;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.R;
+import androidx.core.view.AccessibilityDelegateCompat;
+import androidx.core.view.DifferentialMotionFlingController;
+import androidx.core.view.DifferentialMotionFlingTarget;
+import androidx.core.view.MotionEventCompat;
+import androidx.core.view.NestedScrollingChild3;
+import androidx.core.view.NestedScrollingChildHelper;
+import androidx.core.view.NestedScrollingParent3;
+import androidx.core.view.NestedScrollingParentHelper;
+import androidx.core.view.ScrollingView;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import androidx.core.view.accessibility.AccessibilityRecordCompat;
+import androidx.core.widget.EdgeEffectCompat;
+
+import java.util.List;
+
+/**
+ * A copy of the {@link androidx.core.widget.NestedScrollView} (from
+ * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar)
+ * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}:
+ * the method's body is extracted into the new protected method,
+ * {@link #onRequestChildFocus(View, View)}.
+ * <p>
+ * For the exact change see NestedScrollView.java.patch file.
+ * </p>
+ */
+public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
+ NestedScrollingChild3, ScrollingView {
+ static final int ANIMATED_SCROLL_GAP = 250;
+
+ static final float MAX_SCROLL_FACTOR = 0.5f;
+
+ private static final String TAG = "NestedScrollView";
+ private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 250;
+
+ /**
+ * The following are copied from OverScroller to determine how far a fling will go.
+ */
+ private static final float SCROLL_FRICTION = 0.015f;
+ private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1)
+ private static final float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9));
+ private final float mPhysicalCoeff;
+
+ /**
+ * When flinging the stretch towards scrolling content, it should destretch quicker than the
+ * fling would normally do. The visual effect of flinging the stretch looks strange as little
+ * appears to happen at first and then when the stretch disappears, the content starts
+ * scrolling quickly.
+ */
+ private static final float FLING_DESTRETCH_FACTOR = 4f;
+
+ /**
+ * Interface definition for a callback to be invoked when the scroll
+ * X or Y positions of a view change.
+ *
+ * <p>This version of the interface works on all versions of Android, back to API v4.</p>
+ *
+ * @see #setOnScrollChangeListener(OnScrollChangeListener)
+ */
+ public interface OnScrollChangeListener {
+ /**
+ * Called when the scroll position of a view changes.
+ * @param v The view whose scroll position has changed.
+ * @param scrollX Current horizontal scroll origin.
+ * @param scrollY Current vertical scroll origin.
+ * @param oldScrollX Previous horizontal scroll origin.
+ * @param oldScrollY Previous vertical scroll origin.
+ */
+ void onScrollChange(@NonNull NestedScrollView v, int scrollX, int scrollY,
+ int oldScrollX, int oldScrollY);
+ }
+
+ private long mLastScroll;
+
+ private final Rect mTempRect = new Rect();
+ private OverScroller mScroller;
+
+ @RestrictTo(LIBRARY)
+ @VisibleForTesting
+ @NonNull
+ public EdgeEffect mEdgeGlowTop;
+
+ @RestrictTo(LIBRARY)
+ @VisibleForTesting
+ @NonNull
+ public EdgeEffect mEdgeGlowBottom;
+
+ /**
+ * Position of the last motion event; only used with touch related events (usually to assist
+ * in movement changes in a drag gesture).
+ */
+ private int mLastMotionY;
+
+ /**
+ * True when the layout has changed but the traversal has not come through yet.
+ * Ideally the view hierarchy would keep track of this for us.
+ */
+ private boolean mIsLayoutDirty = true;
+ private boolean mIsLaidOut = false;
+
+ /**
+ * The child to give focus to in the event that a child has requested focus while the
+ * layout is dirty. This prevents the scroll from being wrong if the child has not been
+ * laid out before requesting focus.
+ */
+ private View mChildToScrollTo = null;
+
+ /**
+ * True if the user is currently dragging this ScrollView around. This is
+ * not the same as 'is being flinged', which can be checked by
+ * mScroller.isFinished() (flinging begins when the user lifts their finger).
+ */
+ private boolean mIsBeingDragged = false;
+
+ /**
+ * Determines speed during touch scrolling
+ */
+ private VelocityTracker mVelocityTracker;
+
+ /**
+ * When set to true, the scroll view measure its child to make it fill the currently
+ * visible area.
+ */
+ private boolean mFillViewport;
+
+ /**
+ * Whether arrow scrolling is animated.
+ */
+ private boolean mSmoothScrollingEnabled = true;
+
+ private int mTouchSlop;
+ private int mMinimumVelocity;
+ private int mMaximumVelocity;
+
+ /**
+ * ID of the active pointer. This is used to retain consistency during
+ * drags/flings if multiple pointers are used.
+ */
+ private int mActivePointerId = INVALID_POINTER;
+
+ /**
+ * Used during scrolling to retrieve the new offset within the window. Saves memory by saving
+ * x, y changes to this array (0 position = x, 1 position = y) vs. reallocating an x and y
+ * every time.
+ */
+ private final int[] mScrollOffset = new int[2];
+
+ /*
+ * Used during scrolling to retrieve the new consumed offset within the window.
+ * Uses same memory saving strategy as mScrollOffset.
+ */
+ private final int[] mScrollConsumed = new int[2];
+
+ // Used to track the position of the touch only events relative to the container.
+ private int mNestedYOffset;
+
+ private int mLastScrollerY;
+
+ /**
+ * Sentinel value for no current active pointer.
+ * Used by {@link #mActivePointerId}.
+ */
+ private static final int INVALID_POINTER = -1;
+
+ private SavedState mSavedState;
+
+ private static final AccessibilityDelegate ACCESSIBILITY_DELEGATE = new AccessibilityDelegate();
+
+ private static final int[] SCROLLVIEW_STYLEABLE = new int[] {
+ android.R.attr.fillViewport
+ };
+
+ private final NestedScrollingParentHelper mParentHelper;
+ private final NestedScrollingChildHelper mChildHelper;
+
+ private float mVerticalScrollFactor;
+
+ private OnScrollChangeListener mOnScrollChangeListener;
+
+ @VisibleForTesting
+ final DifferentialMotionFlingTargetImpl mDifferentialMotionFlingTarget =
+ new DifferentialMotionFlingTargetImpl();
+
+ @VisibleForTesting
+ DifferentialMotionFlingController mDifferentialMotionFlingController =
+ new DifferentialMotionFlingController(getContext(), mDifferentialMotionFlingTarget);
+
+ public NestedScrollView(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, R.attr.nestedScrollViewStyle);
+ }
+
+ public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs,
+ int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ mEdgeGlowTop = EdgeEffectCompat.create(context, attrs);
+ mEdgeGlowBottom = EdgeEffectCompat.create(context, attrs);
+
+ final float ppi = context.getResources().getDisplayMetrics().density * 160.0f;
+ mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2)
+ * 39.37f // inch/meter
+ * ppi
+ * 0.84f; // look and feel tuning
+
+ initScrollView();
+
+ final TypedArray a = context.obtainStyledAttributes(
+ attrs, SCROLLVIEW_STYLEABLE, defStyleAttr, 0);
+
+ setFillViewport(a.getBoolean(0, false));
+
+ a.recycle();
+
+ mParentHelper = new NestedScrollingParentHelper(this);
+ mChildHelper = new NestedScrollingChildHelper(this);
+
+ // ...because why else would you be using this widget?
+ setNestedScrollingEnabled(true);
+
+ ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE);
+ }
+
+ // NestedScrollingChild3
+
+ @Override
+ public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type, @NonNull int[] consumed) {
+ mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
+ offsetInWindow, type, consumed);
+ }
+
+ // NestedScrollingChild2
+
+ @Override
+ public boolean startNestedScroll(int axes, int type) {
+ return mChildHelper.startNestedScroll(axes, type);
+ }
+
+ @Override
+ public void stopNestedScroll(int type) {
+ mChildHelper.stopNestedScroll(type);
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent(int type) {
+ return mChildHelper.hasNestedScrollingParent(type);
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+ return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
+ offsetInWindow, type);
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(
+ int dx,
+ int dy,
+ @Nullable int[] consumed,
+ @Nullable int[] offsetInWindow,
+ int type
+ ) {
+ return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
+ }
+
+ // NestedScrollingChild
+
+ @Override
+ public void setNestedScrollingEnabled(boolean enabled) {
+ mChildHelper.setNestedScrollingEnabled(enabled);
+ }
+
+ @Override
+ public boolean isNestedScrollingEnabled() {
+ return mChildHelper.isNestedScrollingEnabled();
+ }
+
+ @Override
+ public boolean startNestedScroll(int axes) {
+ return startNestedScroll(axes, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public void stopNestedScroll() {
+ stopNestedScroll(ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent() {
+ return hasNestedScrollingParent(ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow) {
+ return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
+ offsetInWindow);
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
+ @Nullable int[] offsetInWindow) {
+ return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
+ return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
+ }
+
+ @Override
+ public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
+ return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
+ }
+
+ // NestedScrollingParent3
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
+ onNestedScrollInternal(dyUnconsumed, type, consumed);
+ }
+
+ private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
+ final int oldScrollY = getScrollY();
+ scrollBy(0, dyUnconsumed);
+ final int myConsumed = getScrollY() - oldScrollY;
+
+ if (consumed != null) {
+ consumed[1] += myConsumed;
+ }
+ final int myUnconsumed = dyUnconsumed - myConsumed;
+
+ mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
+ }
+
+ // NestedScrollingParent2
+
+ @Override
+ public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+ return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+ mParentHelper.onNestedScrollAccepted(child, target, axes, type);
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
+ }
+
+ @Override
+ public void onStopNestedScroll(@NonNull View target, int type) {
+ mParentHelper.onStopNestedScroll(target, type);
+ stopNestedScroll(type);
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type) {
+ onNestedScrollInternal(dyUnconsumed, type, null);
+ }
+
+ @Override
+ public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
+ int type) {
+ dispatchNestedPreScroll(dx, dy, consumed, null, type);
+ }
+
+ // NestedScrollingParent
+
+ @Override
+ public boolean onStartNestedScroll(
+ @NonNull View child, @NonNull View target, int axes) {
+ return onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public void onNestedScrollAccepted(
+ @NonNull View child, @NonNull View target, int axes) {
+ onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public void onStopNestedScroll(@NonNull View target) {
+ onStopNestedScroll(target, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed) {
+ onNestedScrollInternal(dyUnconsumed, ViewCompat.TYPE_TOUCH, null);
+ }
+
+ @Override
+ public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
+ onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH);
+ }
+
+ @Override
+ public boolean onNestedFling(
+ @NonNull View target, float velocityX, float velocityY, boolean consumed) {
+ if (!consumed) {
+ dispatchNestedFling(0, velocityY, true);
+ fling((int) velocityY);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
+ return dispatchNestedPreFling(velocityX, velocityY);
+ }
+
+ @Override
+ public int getNestedScrollAxes() {
+ return mParentHelper.getNestedScrollAxes();
+ }
+
+ // ScrollView import
+
+ @Override
+ public boolean shouldDelayChildPressedState() {
+ return true;
+ }
+
+ @Override
+ protected float getTopFadingEdgeStrength() {
+ if (getChildCount() == 0) {
+ return 0.0f;
+ }
+
+ final int length = getVerticalFadingEdgeLength();
+ final int scrollY = getScrollY();
+ if (scrollY < length) {
+ return scrollY / (float) length;
+ }
+
+ return 1.0f;
+ }
+
+ @Override
+ protected float getBottomFadingEdgeStrength() {
+ if (getChildCount() == 0) {
+ return 0.0f;
+ }
+
+ View child = getChildAt(0);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ final int length = getVerticalFadingEdgeLength();
+ final int bottomEdge = getHeight() - getPaddingBottom();
+ final int span = child.getBottom() + lp.bottomMargin - getScrollY() - bottomEdge;
+ if (span < length) {
+ return span / (float) length;
+ }
+
+ return 1.0f;
+ }
+
+ /**
+ * @return The maximum amount this scroll view will scroll in response to
+ * an arrow event.
+ */
+ public int getMaxScrollAmount() {
+ return (int) (MAX_SCROLL_FACTOR * getHeight());
+ }
+
+ private void initScrollView() {
+ mScroller = new OverScroller(getContext());
+ setFocusable(true);
+ setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
+ setWillNotDraw(false);
+ final ViewConfiguration configuration = ViewConfiguration.get(getContext());
+ mTouchSlop = configuration.getScaledTouchSlop();
+ mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
+ mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
+ }
+
+ @Override
+ public void addView(@NonNull View child) {
+ if (getChildCount() > 0) {
+ throw new IllegalStateException("ScrollView can host only one direct child");
+ }
+
+ super.addView(child);
+ }
+
+ @Override
+ public void addView(View child, int index) {
+ if (getChildCount() > 0) {
+ throw new IllegalStateException("ScrollView can host only one direct child");
+ }
+
+ super.addView(child, index);
+ }
+
+ @Override
+ public void addView(View child, ViewGroup.LayoutParams params) {
+ if (getChildCount() > 0) {
+ throw new IllegalStateException("ScrollView can host only one direct child");
+ }
+
+ super.addView(child, params);
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ if (getChildCount() > 0) {
+ throw new IllegalStateException("ScrollView can host only one direct child");
+ }
+
+ super.addView(child, index, params);
+ }
+
+ /**
+ * Register a callback to be invoked when the scroll X or Y positions of
+ * this view change.
+ * <p>This version of the method works on all versions of Android, back to API v4.</p>
+ *
+ * @param l The listener to notify when the scroll X or Y position changes.
+ * @see View#getScrollX()
+ * @see View#getScrollY()
+ */
+ public void setOnScrollChangeListener(@Nullable OnScrollChangeListener l) {
+ mOnScrollChangeListener = l;
+ }
+
+ /**
+ * @return Returns true this ScrollView can be scrolled
+ */
+ private boolean canScroll() {
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin;
+ int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom();
+ return childSize > parentSpace;
+ }
+ return false;
+ }
+
+ /**
+ * Indicates whether this ScrollView's content is stretched to fill the viewport.
+ *
+ * @return True if the content fills the viewport, false otherwise.
+ *
+ * @attr name android:fillViewport
+ */
+ public boolean isFillViewport() {
+ return mFillViewport;
+ }
+
+ /**
+ * Set whether this ScrollView should stretch its content height to fill the viewport or not.
+ *
+ * @param fillViewport True to stretch the content's height to the viewport's
+ * boundaries, false otherwise.
+ *
+ * @attr name android:fillViewport
+ */
+ public void setFillViewport(boolean fillViewport) {
+ if (fillViewport != mFillViewport) {
+ mFillViewport = fillViewport;
+ requestLayout();
+ }
+ }
+
+ /**
+ * @return Whether arrow scrolling will animate its transition.
+ */
+ public boolean isSmoothScrollingEnabled() {
+ return mSmoothScrollingEnabled;
+ }
+
+ /**
+ * Set whether arrow scrolling will animate its transition.
+ * @param smoothScrollingEnabled whether arrow scrolling will animate its transition
+ */
+ public void setSmoothScrollingEnabled(boolean smoothScrollingEnabled) {
+ mSmoothScrollingEnabled = smoothScrollingEnabled;
+ }
+
+ @Override
+ protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+ super.onScrollChanged(l, t, oldl, oldt);
+
+ if (mOnScrollChangeListener != null) {
+ mOnScrollChangeListener.onScrollChange(this, l, t, oldl, oldt);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ if (!mFillViewport) {
+ return;
+ }
+
+ final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
+ if (heightMode == MeasureSpec.UNSPECIFIED) {
+ return;
+ }
+
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+
+ int childSize = child.getMeasuredHeight();
+ int parentSpace = getMeasuredHeight()
+ - getPaddingTop()
+ - getPaddingBottom()
+ - lp.topMargin
+ - lp.bottomMargin;
+
+ if (childSize < parentSpace) {
+ int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
+ getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin,
+ lp.width);
+ int childHeightMeasureSpec =
+ MeasureSpec.makeMeasureSpec(parentSpace, MeasureSpec.EXACTLY);
+ child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
+ }
+ }
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // Let the focused view and/or our descendants get the key first
+ return super.dispatchKeyEvent(event) || executeKeyEvent(event);
+ }
+
+ /**
+ * You can call this function yourself to have the scroll view perform
+ * scrolling from a key event, just as if the event had been dispatched to
+ * it by the view hierarchy.
+ *
+ * @param event The key event to execute.
+ * @return Return true if the event was handled, else false.
+ */
+ public boolean executeKeyEvent(@NonNull KeyEvent event) {
+ mTempRect.setEmpty();
+
+ if (!canScroll()) {
+ if (isFocused() && event.getKeyCode() != KeyEvent.KEYCODE_BACK) {
+ View currentFocused = findFocus();
+ if (currentFocused == this) currentFocused = null;
+ View nextFocused = FocusFinder.getInstance().findNextFocus(this,
+ currentFocused, View.FOCUS_DOWN);
+ return nextFocused != null
+ && nextFocused != this
+ && nextFocused.requestFocus(View.FOCUS_DOWN);
+ }
+ return false;
+ }
+
+ boolean handled = false;
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_UP:
+ if (event.isAltPressed()) {
+ handled = fullScroll(View.FOCUS_UP);
+ } else {
+ handled = arrowScroll(View.FOCUS_UP);
+ }
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ if (event.isAltPressed()) {
+ handled = fullScroll(View.FOCUS_DOWN);
+ } else {
+ handled = arrowScroll(View.FOCUS_DOWN);
+ }
+ break;
+ case KeyEvent.KEYCODE_PAGE_UP:
+ handled = fullScroll(View.FOCUS_UP);
+ break;
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ handled = fullScroll(View.FOCUS_DOWN);
+ break;
+ case KeyEvent.KEYCODE_SPACE:
+ pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN);
+ break;
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ pageScroll(View.FOCUS_UP);
+ break;
+ case KeyEvent.KEYCODE_MOVE_END:
+ pageScroll(View.FOCUS_DOWN);
+ break;
+ }
+ }
+
+ return handled;
+ }
+
+ private boolean inChild(int x, int y) {
+ if (getChildCount() > 0) {
+ final int scrollY = getScrollY();
+ final View child = getChildAt(0);
+ return !(y < child.getTop() - scrollY
+ || y >= child.getBottom() - scrollY
+ || x < child.getLeft()
+ || x >= child.getRight());
+ }
+ return false;
+ }
+
+ private void initOrResetVelocityTracker() {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ } else {
+ mVelocityTracker.clear();
+ }
+ }
+
+ private void initVelocityTrackerIfNotExists() {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ }
+
+ private void recycleVelocityTracker() {
+ if (mVelocityTracker != null) {
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ }
+ }
+
+ @Override
+ public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+ if (disallowIntercept) {
+ recycleVelocityTracker();
+ }
+ super.requestDisallowInterceptTouchEvent(disallowIntercept);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(@NonNull MotionEvent ev) {
+ /*
+ * This method JUST determines whether we want to intercept the motion.
+ * If we return true, onMotionEvent will be called and we do the actual
+ * scrolling there.
+ */
+
+ /*
+ * Shortcut the most recurring case: the user is in the dragging
+ * state and they are moving their finger. We want to intercept this
+ * motion.
+ */
+ final int action = ev.getAction();
+ if ((action == MotionEvent.ACTION_MOVE) && mIsBeingDragged) {
+ return true;
+ }
+
+ switch (action & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_MOVE: {
+ /*
+ * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
+ * whether the user has moved far enough from their original down touch.
+ */
+
+ /*
+ * Locally do absolute value. mLastMotionY is set to the y value
+ * of the down event.
+ */
+ final int activePointerId = mActivePointerId;
+ if (activePointerId == INVALID_POINTER) {
+ // If we don't have a valid id, the touch down wasn't on content.
+ break;
+ }
+
+ final int pointerIndex = ev.findPointerIndex(activePointerId);
+ if (pointerIndex == -1) {
+ Log.e(TAG, "Invalid pointerId=" + activePointerId
+ + " in onInterceptTouchEvent");
+ break;
+ }
+
+ final int y = (int) ev.getY(pointerIndex);
+ final int yDiff = Math.abs(y - mLastMotionY);
+ if (yDiff > mTouchSlop
+ && (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) {
+ mIsBeingDragged = true;
+ mLastMotionY = y;
+ initVelocityTrackerIfNotExists();
+ mVelocityTracker.addMovement(ev);
+ mNestedYOffset = 0;
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ parent.requestDisallowInterceptTouchEvent(true);
+ }
+ }
+ break;
+ }
+
+ case MotionEvent.ACTION_DOWN: {
+ final int y = (int) ev.getY();
+ if (!inChild((int) ev.getX(), y)) {
+ mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished();
+ recycleVelocityTracker();
+ break;
+ }
+
+ /*
+ * Remember location of down touch.
+ * ACTION_DOWN always refers to pointer index 0.
+ */
+ mLastMotionY = y;
+ mActivePointerId = ev.getPointerId(0);
+
+ initOrResetVelocityTracker();
+ mVelocityTracker.addMovement(ev);
+ /*
+ * If being flinged and user touches the screen, initiate drag;
+ * otherwise don't. mScroller.isFinished should be false when
+ * being flinged. We also want to catch the edge glow and start dragging
+ * if one is being animated. We need to call computeScrollOffset() first so that
+ * isFinished() is correct.
+ */
+ mScroller.computeScrollOffset();
+ mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished();
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ /* Release the drag */
+ mIsBeingDragged = false;
+ mActivePointerId = INVALID_POINTER;
+ recycleVelocityTracker();
+ if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) {
+ postInvalidateOnAnimation();
+ }
+ stopNestedScroll(ViewCompat.TYPE_TOUCH);
+ break;
+ case MotionEvent.ACTION_POINTER_UP:
+ onSecondaryPointerUp(ev);
+ break;
+ }
+
+ /*
+ * The only time we want to intercept motion events is if we are in the
+ * drag mode.
+ */
+ return mIsBeingDragged;
+ }
+
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent motionEvent) {
+ initVelocityTrackerIfNotExists();
+
+ final int actionMasked = motionEvent.getActionMasked();
+
+ if (actionMasked == MotionEvent.ACTION_DOWN) {
+ mNestedYOffset = 0;
+ }
+
+ MotionEvent velocityTrackerMotionEvent = MotionEvent.obtain(motionEvent);
+ velocityTrackerMotionEvent.offsetLocation(0, mNestedYOffset);
+
+ switch (actionMasked) {
+ case MotionEvent.ACTION_DOWN: {
+ if (getChildCount() == 0) {
+ return false;
+ }
+
+ // If additional fingers touch the screen while a drag is in progress, this block
+ // of code will make sure the drag isn't interrupted.
+ if (mIsBeingDragged) {
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ parent.requestDisallowInterceptTouchEvent(true);
+ }
+ }
+
+ /*
+ * If being flinged and user touches, stop the fling. isFinished
+ * will be false if being flinged.
+ */
+ if (!mScroller.isFinished()) {
+ abortAnimatedScroll();
+ }
+
+ initializeTouchDrag(
+ (int) motionEvent.getY(),
+ motionEvent.getPointerId(0)
+ );
+
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ final int activePointerIndex = motionEvent.findPointerIndex(mActivePointerId);
+ if (activePointerIndex == -1) {
+ Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
+ break;
+ }
+
+ final int y = (int) motionEvent.getY(activePointerIndex);
+ int deltaY = mLastMotionY - y;
+ deltaY -= releaseVerticalGlow(deltaY, motionEvent.getX(activePointerIndex));
+
+ // Changes to dragged state if delta is greater than the slop (and not in
+ // the dragged state).
+ if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ parent.requestDisallowInterceptTouchEvent(true);
+ }
+ mIsBeingDragged = true;
+ if (deltaY > 0) {
+ deltaY -= mTouchSlop;
+ } else {
+ deltaY += mTouchSlop;
+ }
+ }
+
+ if (mIsBeingDragged) {
+ final int x = (int) motionEvent.getX(activePointerIndex);
+ int scrollOffset = scrollBy(deltaY, x, ViewCompat.TYPE_TOUCH, false);
+ // Updates the global positions (used by later move events to properly scroll).
+ mLastMotionY = y - scrollOffset;
+ mNestedYOffset += scrollOffset;
+ }
+ break;
+ }
+
+ case MotionEvent.ACTION_UP: {
+ final VelocityTracker velocityTracker = mVelocityTracker;
+ velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
+ int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
+ if ((Math.abs(initialVelocity) >= mMinimumVelocity)) {
+ if (!edgeEffectFling(initialVelocity)
+ && !dispatchNestedPreFling(0, -initialVelocity)) {
+ dispatchNestedFling(0, -initialVelocity, true);
+ fling(-initialVelocity);
+ }
+ } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
+ getScrollRange())) {
+ postInvalidateOnAnimation();
+ }
+ endTouchDrag();
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL: {
+ if (mIsBeingDragged && getChildCount() > 0) {
+ if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
+ getScrollRange())) {
+ postInvalidateOnAnimation();
+ }
+ }
+ endTouchDrag();
+ break;
+ }
+
+ case MotionEvent.ACTION_POINTER_DOWN: {
+ final int index = motionEvent.getActionIndex();
+ mLastMotionY = (int) motionEvent.getY(index);
+ mActivePointerId = motionEvent.getPointerId(index);
+ break;
+ }
+
+ case MotionEvent.ACTION_POINTER_UP: {
+ onSecondaryPointerUp(motionEvent);
+ mLastMotionY =
+ (int) motionEvent.getY(motionEvent.findPointerIndex(mActivePointerId));
+ break;
+ }
+ }
+
+ if (mVelocityTracker != null) {
+ mVelocityTracker.addMovement(velocityTrackerMotionEvent);
+ }
+ // Returns object back to be re-used by others.
+ velocityTrackerMotionEvent.recycle();
+
+ return true;
+ }
+
+ private void initializeTouchDrag(int lastMotionY, int activePointerId) {
+ mLastMotionY = lastMotionY;
+ mActivePointerId = activePointerId;
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
+ }
+
+ // Ends drag in a nested scroll.
+ private void endTouchDrag() {
+ mActivePointerId = INVALID_POINTER;
+ mIsBeingDragged = false;
+
+ recycleVelocityTracker();
+ stopNestedScroll(ViewCompat.TYPE_TOUCH);
+
+ mEdgeGlowTop.onRelease();
+ mEdgeGlowBottom.onRelease();
+ }
+
+ /*
+ * Handles scroll events for both touch and non-touch events (mouse scroll wheel,
+ * rotary button, keyboard, etc.).
+ *
+ * Note: This function returns the total scroll offset for this scroll event which is required
+ * for calculating the total scroll between multiple move events (touch). This returned value
+ * is NOT needed for non-touch events since a scroll is a one time event (vs. touch where a
+ * drag may be triggered multiple times with the movement of the finger).
+ */
+ // TODO: You should rename this to nestedScrollBy() so it is different from View.scrollBy
+ private int scrollBy(
+ int verticalScrollDistance,
+ int x,
+ int touchType,
+ boolean isSourceMouseOrKeyboard
+ ) {
+ int totalScrollOffset = 0;
+
+ /*
+ * Starts nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.).
+ * This is in contrast to a touch event which would trigger the start of nested scrolling
+ * with a touch down event outside of this method, since for a single gesture scrollBy()
+ * might be called several times for a move event for a single drag gesture.
+ */
+ if (touchType == ViewCompat.TYPE_NON_TOUCH) {
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, touchType);
+ }
+
+ // Dispatches scrolling delta amount available to parent (to consume what it needs).
+ // Note: The amounts the parent consumes are saved in arrays named mScrollConsumed and
+ // mScrollConsumed to save space.
+ if (dispatchNestedPreScroll(
+ 0,
+ verticalScrollDistance,
+ mScrollConsumed,
+ mScrollOffset,
+ touchType)
+ ) {
+ // Deducts the scroll amount (y) consumed by the parent (x in position 0,
+ // y in position 1). Nested scroll only works with Y position (so we don't use x).
+ verticalScrollDistance -= mScrollConsumed[1];
+ totalScrollOffset += mScrollOffset[1];
+ }
+
+ // Retrieves the scroll y position (top position of this view) and scroll Y range (how far
+ // the scroll can go).
+ final int initialScrollY = getScrollY();
+ final int scrollRangeY = getScrollRange();
+
+ // Overscroll is for adding animations at the top/bottom of a view when the user scrolls
+ // beyond the beginning/end of the view. Overscroll is not used with a mouse.
+ boolean canOverscroll = canOverScroll() && !isSourceMouseOrKeyboard;
+
+ // Scrolls content in the current View, but clamps it if it goes too far.
+ boolean hitScrollBarrier =
+ overScrollByCompat(
+ 0,
+ verticalScrollDistance,
+ 0,
+ initialScrollY,
+ 0,
+ scrollRangeY,
+ 0,
+ 0,
+ true
+ ) && !hasNestedScrollingParent(touchType);
+
+ // The position may have been adjusted in the previous call, so we must revise our values.
+ final int scrollYDelta = getScrollY() - initialScrollY;
+ final int unconsumedY = verticalScrollDistance - scrollYDelta;
+
+ // Reset the Y consumed scroll to zero
+ mScrollConsumed[1] = 0;
+
+ // Dispatch the unconsumed delta Y to the children to consume.
+ dispatchNestedScroll(
+ 0,
+ scrollYDelta,
+ 0,
+ unconsumedY,
+ mScrollOffset,
+ touchType,
+ mScrollConsumed
+ );
+
+ totalScrollOffset += mScrollOffset[1];
+
+ // Handle overscroll of the children.
+ verticalScrollDistance -= mScrollConsumed[1];
+ int newScrollY = initialScrollY + verticalScrollDistance;
+
+ if (newScrollY < 0) {
+ if (canOverscroll) {
+ EdgeEffectCompat.onPullDistance(
+ mEdgeGlowTop,
+ (float) -verticalScrollDistance / getHeight(),
+ (float) x / getWidth()
+ );
+
+ if (!mEdgeGlowBottom.isFinished()) {
+ mEdgeGlowBottom.onRelease();
+ }
+ }
+
+ } else if (newScrollY > scrollRangeY) {
+ if (canOverscroll) {
+ EdgeEffectCompat.onPullDistance(
+ mEdgeGlowBottom,
+ (float) verticalScrollDistance / getHeight(),
+ 1.f - ((float) x / getWidth())
+ );
+
+ if (!mEdgeGlowTop.isFinished()) {
+ mEdgeGlowTop.onRelease();
+ }
+ }
+ }
+
+ if (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished()) {
+ postInvalidateOnAnimation();
+ hitScrollBarrier = false;
+ }
+
+ if (hitScrollBarrier && (touchType == ViewCompat.TYPE_TOUCH)) {
+ // Break our velocity if we hit a scroll barrier.
+ if (mVelocityTracker != null) {
+ mVelocityTracker.clear();
+ }
+ }
+
+ /*
+ * Ends nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.).
+ * As noted above, this is in contrast to a touch event.
+ */
+ if (touchType == ViewCompat.TYPE_NON_TOUCH) {
+ stopNestedScroll(touchType);
+
+ // Required for scrolling with Rotary Device stretch top/bottom to work properly
+ mEdgeGlowTop.onRelease();
+ mEdgeGlowBottom.onRelease();
+ }
+
+ return totalScrollOffset;
+ }
+
+ /**
+ * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should
+ * animate with a fling. It will animate with a fling if the velocity will remove the
+ * EdgeEffect through its normal operation.
+ *
+ * @param edgeEffect The EdgeEffect that might absorb the velocity.
+ * @param velocity The velocity of the fling motion
+ * @return true if the velocity should be absorbed or false if it should be flung.
+ */
+ private boolean shouldAbsorb(@NonNull EdgeEffect edgeEffect, int velocity) {
+ if (velocity > 0) {
+ return true;
+ }
+ float distance = EdgeEffectCompat.getDistance(edgeEffect) * getHeight();
+
+ // This is flinging without the spring, so let's see if it will fling past the overscroll
+ float flingDistance = getSplineFlingDistance(-velocity);
+
+ return flingDistance < distance;
+ }
+
+ /**
+ * If mTopGlow or mBottomGlow is currently active and the motion will remove some of the
+ * stretch, this will consume any of unconsumedY that the glow can. If the motion would
+ * increase the stretch, or the EdgeEffect isn't a stretch, then nothing will be consumed.
+ *
+ * @param unconsumedY The vertical delta that might be consumed by the vertical EdgeEffects
+ * @return The remaining unconsumed delta after the edge effects have consumed.
+ */
+ int consumeFlingInVerticalStretch(int unconsumedY) {
+ int height = getHeight();
+ if (unconsumedY > 0 && EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0f) {
+ float deltaDistance = -unconsumedY * FLING_DESTRETCH_FACTOR / height;
+ int consumed = Math.round(-height / FLING_DESTRETCH_FACTOR
+ * EdgeEffectCompat.onPullDistance(mEdgeGlowTop, deltaDistance, 0.5f));
+ if (consumed != unconsumedY) {
+ mEdgeGlowTop.finish();
+ }
+ return unconsumedY - consumed;
+ }
+ if (unconsumedY < 0 && EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0f) {
+ float deltaDistance = unconsumedY * FLING_DESTRETCH_FACTOR / height;
+ int consumed = Math.round(height / FLING_DESTRETCH_FACTOR
+ * EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, deltaDistance, 0.5f));
+ if (consumed != unconsumedY) {
+ mEdgeGlowBottom.finish();
+ }
+ return unconsumedY - consumed;
+ }
+ return unconsumedY;
+ }
+
+ /**
+ * Copied from OverScroller, this returns the distance that a fling with the given velocity
+ * will go.
+ * @param velocity The velocity of the fling
+ * @return The distance that will be traveled by a fling of the given velocity.
+ */
+ private float getSplineFlingDistance(int velocity) {
+ final double l =
+ Math.log(INFLEXION * Math.abs(velocity) / (SCROLL_FRICTION * mPhysicalCoeff));
+ final double decelMinusOne = DECELERATION_RATE - 1.0;
+ return (float) (SCROLL_FRICTION * mPhysicalCoeff
+ * Math.exp(DECELERATION_RATE / decelMinusOne * l));
+ }
+
+ private boolean edgeEffectFling(int velocityY) {
+ boolean consumed = true;
+ if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) {
+ if (shouldAbsorb(mEdgeGlowTop, velocityY)) {
+ mEdgeGlowTop.onAbsorb(velocityY);
+ } else {
+ fling(-velocityY);
+ }
+ } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) {
+ if (shouldAbsorb(mEdgeGlowBottom, -velocityY)) {
+ mEdgeGlowBottom.onAbsorb(-velocityY);
+ } else {
+ fling(-velocityY);
+ }
+ } else {
+ consumed = false;
+ }
+ return consumed;
+ }
+
+ /**
+ * This stops any edge glow animation that is currently running by applying a
+ * 0 length pull at the displacement given by the provided MotionEvent. On pre-S devices,
+ * this method does nothing, allowing any animating edge effect to continue animating and
+ * returning <code>false</code> always.
+ *
+ * @param e The motion event to use to indicate the finger position for the displacement of
+ * the current pull.
+ * @return <code>true</code> if any edge effect had an existing effect to be drawn ond the
+ * animation was stopped or <code>false</code> if no edge effect had a value to display.
+ */
+ private boolean stopGlowAnimations(MotionEvent e) {
+ boolean stopped = false;
+ if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) {
+ EdgeEffectCompat.onPullDistance(mEdgeGlowTop, 0, e.getX() / getWidth());
+ stopped = true;
+ }
+ if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) {
+ EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, 0, 1 - e.getX() / getWidth());
+ stopped = true;
+ }
+ return stopped;
+ }
+
+ private void onSecondaryPointerUp(MotionEvent ev) {
+ final int pointerIndex = ev.getActionIndex();
+ final int pointerId = ev.getPointerId(pointerIndex);
+ if (pointerId == mActivePointerId) {
+ // This was our active pointer going up. Choose a new
+ // active pointer and adjust accordingly.
+ // TODO: Make this decision more intelligent.
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mLastMotionY = (int) ev.getY(newPointerIndex);
+ mActivePointerId = ev.getPointerId(newPointerIndex);
+ if (mVelocityTracker != null) {
+ mVelocityTracker.clear();
+ }
+ }
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(@NonNull MotionEvent motionEvent) {
+ if (motionEvent.getAction() == MotionEvent.ACTION_SCROLL && !mIsBeingDragged) {
+ final float verticalScroll;
+ final int x;
+ final int flingAxis;
+
+ if (MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_CLASS_POINTER)) {
+ verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_VSCROLL);
+ x = (int) motionEvent.getX();
+ flingAxis = MotionEvent.AXIS_VSCROLL;
+ } else if (
+ MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_ROTARY_ENCODER)
+ ) {
+ verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
+ // Since a Wear rotary event doesn't have a true X and we want to support proper
+ // overscroll animations, we put the x at the center of the screen.
+ x = getWidth() / 2;
+ flingAxis = MotionEvent.AXIS_SCROLL;
+ } else {
+ verticalScroll = 0;
+ x = 0;
+ flingAxis = 0;
+ }
+
+ if (verticalScroll != 0) {
+ // Rotary and Mouse scrolls are inverted from a touch scroll.
+ final int invertedDelta = (int) (verticalScroll * getVerticalScrollFactorCompat());
+
+ final boolean isSourceMouse =
+ MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_MOUSE);
+
+ scrollBy(-invertedDelta, x, ViewCompat.TYPE_NON_TOUCH, isSourceMouse);
+ if (flingAxis != 0) {
+ mDifferentialMotionFlingController.onMotionEvent(motionEvent, flingAxis);
+ }
+
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the NestedScrollView supports over scroll.
+ */
+ private boolean canOverScroll() {
+ final int mode = getOverScrollMode();
+ return mode == OVER_SCROLL_ALWAYS
+ || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && getScrollRange() > 0);
+ }
+
+ @VisibleForTesting
+ float getVerticalScrollFactorCompat() {
+ if (mVerticalScrollFactor == 0) {
+ TypedValue outValue = new TypedValue();
+ final Context context = getContext();
+ if (!context.getTheme().resolveAttribute(
+ android.R.attr.listPreferredItemHeight, outValue, true)) {
+ throw new IllegalStateException(
+ "Expected theme to define listPreferredItemHeight.");
+ }
+ mVerticalScrollFactor = outValue.getDimension(
+ context.getResources().getDisplayMetrics());
+ }
+ return mVerticalScrollFactor;
+ }
+
+ @Override
+ protected void onOverScrolled(int scrollX, int scrollY,
+ boolean clampedX, boolean clampedY) {
+ super.scrollTo(scrollX, scrollY);
+ }
+
+ @SuppressWarnings({"SameParameterValue", "unused"})
+ boolean overScrollByCompat(int deltaX, int deltaY,
+ int scrollX, int scrollY,
+ int scrollRangeX, int scrollRangeY,
+ int maxOverScrollX, int maxOverScrollY,
+ boolean isTouchEvent) {
+
+ final int overScrollMode = getOverScrollMode();
+ final boolean canScrollHorizontal =
+ computeHorizontalScrollRange() > computeHorizontalScrollExtent();
+ final boolean canScrollVertical =
+ computeVerticalScrollRange() > computeVerticalScrollExtent();
+
+ final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS
+ || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal);
+ final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS
+ || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical);
+
+ int newScrollX = scrollX + deltaX;
+ if (!overScrollHorizontal) {
+ maxOverScrollX = 0;
+ }
+
+ int newScrollY = scrollY + deltaY;
+ if (!overScrollVertical) {
+ maxOverScrollY = 0;
+ }
+
+ // Clamp values if at the limits and record
+ final int left = -maxOverScrollX;
+ final int right = maxOverScrollX + scrollRangeX;
+ final int top = -maxOverScrollY;
+ final int bottom = maxOverScrollY + scrollRangeY;
+
+ boolean clampedX = false;
+ if (newScrollX > right) {
+ newScrollX = right;
+ clampedX = true;
+ } else if (newScrollX < left) {
+ newScrollX = left;
+ clampedX = true;
+ }
+
+ boolean clampedY = false;
+ if (newScrollY > bottom) {
+ newScrollY = bottom;
+ clampedY = true;
+ } else if (newScrollY < top) {
+ newScrollY = top;
+ clampedY = true;
+ }
+
+ if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
+ mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
+ }
+
+ onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
+
+ return clampedX || clampedY;
+ }
+
+ int getScrollRange() {
+ int scrollRange = 0;
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin;
+ int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom();
+ scrollRange = Math.max(0, childSize - parentSpace);
+ }
+ return scrollRange;
+ }
+
+ /**
+ * <p>
+ * Finds the next focusable component that fits in the specified bounds.
+ * </p>
+ *
+ * @param topFocus look for a candidate is the one at the top of the bounds
+ * if topFocus is true, or at the bottom of the bounds if topFocus is
+ * false
+ * @param top the top offset of the bounds in which a focusable must be
+ * found
+ * @param bottom the bottom offset of the bounds in which a focusable must
+ * be found
+ * @return the next focusable component in the bounds or null if none can
+ * be found
+ */
+ private View findFocusableViewInBounds(boolean topFocus, int top, int bottom) {
+
+ List<View> focusables = getFocusables(View.FOCUS_FORWARD);
+ View focusCandidate = null;
+
+ /*
+ * A fully contained focusable is one where its top is below the bound's
+ * top, and its bottom is above the bound's bottom. A partially
+ * contained focusable is one where some part of it is within the
+ * bounds, but it also has some part that is not within bounds. A fully contained
+ * focusable is preferred to a partially contained focusable.
+ */
+ boolean foundFullyContainedFocusable = false;
+
+ int count = focusables.size();
+ for (int i = 0; i < count; i++) {
+ View view = focusables.get(i);
+ int viewTop = view.getTop();
+ int viewBottom = view.getBottom();
+
+ if (top < viewBottom && viewTop < bottom) {
+ /*
+ * the focusable is in the target area, it is a candidate for
+ * focusing
+ */
+
+ final boolean viewIsFullyContained = (top < viewTop) && (viewBottom < bottom);
+
+ if (focusCandidate == null) {
+ /* No candidate, take this one */
+ focusCandidate = view;
+ foundFullyContainedFocusable = viewIsFullyContained;
+ } else {
+ final boolean viewIsCloserToBoundary =
+ (topFocus && viewTop < focusCandidate.getTop())
+ || (!topFocus && viewBottom > focusCandidate.getBottom());
+
+ if (foundFullyContainedFocusable) {
+ if (viewIsFullyContained && viewIsCloserToBoundary) {
+ /*
+ * We're dealing with only fully contained views, so
+ * it has to be closer to the boundary to beat our
+ * candidate
+ */
+ focusCandidate = view;
+ }
+ } else {
+ if (viewIsFullyContained) {
+ /* Any fully contained view beats a partially contained view */
+ focusCandidate = view;
+ foundFullyContainedFocusable = true;
+ } else if (viewIsCloserToBoundary) {
+ /*
+ * Partially contained view beats another partially
+ * contained view if it's closer
+ */
+ focusCandidate = view;
+ }
+ }
+ }
+ }
+ }
+
+ return focusCandidate;
+ }
+
+ /**
+ * <p>Handles scrolling in response to a "page up/down" shortcut press. This
+ * method will scroll the view by one page up or down and give the focus
+ * to the topmost/bottommost component in the new visible area. If no
+ * component is a good candidate for focus, this scrollview reclaims the
+ * focus.</p>
+ *
+ * @param direction the scroll direction: {@link View#FOCUS_UP}
+ * to go one page up or
+ * {@link View#FOCUS_DOWN} to go one page down
+ * @return true if the key event is consumed by this method, false otherwise
+ */
+ public boolean pageScroll(int direction) {
+ boolean down = direction == View.FOCUS_DOWN;
+ int height = getHeight();
+
+ if (down) {
+ mTempRect.top = getScrollY() + height;
+ int count = getChildCount();
+ if (count > 0) {
+ View view = getChildAt(count - 1);
+ LayoutParams lp = (LayoutParams) view.getLayoutParams();
+ int bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom();
+ if (mTempRect.top + height > bottom) {
+ mTempRect.top = bottom - height;
+ }
+ }
+ } else {
+ mTempRect.top = getScrollY() - height;
+ if (mTempRect.top < 0) {
+ mTempRect.top = 0;
+ }
+ }
+ mTempRect.bottom = mTempRect.top + height;
+
+ return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom);
+ }
+
+ /**
+ * <p>Handles scrolling in response to a "home/end" shortcut press. This
+ * method will scroll the view to the top or bottom and give the focus
+ * to the topmost/bottommost component in the new visible area. If no
+ * component is a good candidate for focus, this scrollview reclaims the
+ * focus.</p>
+ *
+ * @param direction the scroll direction: {@link View#FOCUS_UP}
+ * to go the top of the view or
+ * {@link View#FOCUS_DOWN} to go the bottom
+ * @return true if the key event is consumed by this method, false otherwise
+ */
+ public boolean fullScroll(int direction) {
+ boolean down = direction == View.FOCUS_DOWN;
+ int height = getHeight();
+
+ mTempRect.top = 0;
+ mTempRect.bottom = height;
+
+ if (down) {
+ int count = getChildCount();
+ if (count > 0) {
+ View view = getChildAt(count - 1);
+ LayoutParams lp = (LayoutParams) view.getLayoutParams();
+ mTempRect.bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom();
+ mTempRect.top = mTempRect.bottom - height;
+ }
+ }
+ return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom);
+ }
+
+ /**
+ * <p>Scrolls the view to make the area defined by <code>top</code> and
+ * <code>bottom</code> visible. This method attempts to give the focus
+ * to a component visible in this area. If no component can be focused in
+ * the new visible area, the focus is reclaimed by this ScrollView.</p>
+ *
+ * @param direction the scroll direction: {@link View#FOCUS_UP}
+ * to go upward, {@link View#FOCUS_DOWN} to downward
+ * @param top the top offset of the new area to be made visible
+ * @param bottom the bottom offset of the new area to be made visible
+ * @return true if the key event is consumed by this method, false otherwise
+ */
+ private boolean scrollAndFocus(int direction, int top, int bottom) {
+ boolean handled = true;
+
+ int height = getHeight();
+ int containerTop = getScrollY();
+ int containerBottom = containerTop + height;
+ boolean up = direction == View.FOCUS_UP;
+
+ View newFocused = findFocusableViewInBounds(up, top, bottom);
+ if (newFocused == null) {
+ newFocused = this;
+ }
+
+ if (top >= containerTop && bottom <= containerBottom) {
+ handled = false;
+ } else {
+ int delta = up ? (top - containerTop) : (bottom - containerBottom);
+ scrollBy(delta, 0, ViewCompat.TYPE_NON_TOUCH, true);
+ }
+
+ if (newFocused != findFocus()) newFocused.requestFocus(direction);
+
+ return handled;
+ }
+
+ /**
+ * Handle scrolling in response to an up or down arrow click.
+ *
+ * @param direction The direction corresponding to the arrow key that was
+ * pressed
+ * @return True if we consumed the event, false otherwise
+ */
+ public boolean arrowScroll(int direction) {
+ View currentFocused = findFocus();
+ if (currentFocused == this) currentFocused = null;
+
+ View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction);
+
+ final int maxJump = getMaxScrollAmount();
+
+ if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump, getHeight())) {
+ nextFocused.getDrawingRect(mTempRect);
+ offsetDescendantRectToMyCoords(nextFocused, mTempRect);
+ int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
+
+ scrollBy(scrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true);
+ nextFocused.requestFocus(direction);
+
+ } else {
+ // no new focus
+ int scrollDelta = maxJump;
+
+ if (direction == View.FOCUS_UP && getScrollY() < scrollDelta) {
+ scrollDelta = getScrollY();
+ } else if (direction == View.FOCUS_DOWN) {
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int daBottom = child.getBottom() + lp.bottomMargin;
+ int screenBottom = getScrollY() + getHeight() - getPaddingBottom();
+ scrollDelta = Math.min(daBottom - screenBottom, maxJump);
+ }
+ }
+ if (scrollDelta == 0) {
+ return false;
+ }
+
+ int finalScrollDelta = direction == View.FOCUS_DOWN ? scrollDelta : -scrollDelta;
+ scrollBy(finalScrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true);
+ }
+
+ if (currentFocused != null && currentFocused.isFocused()
+ && isOffScreen(currentFocused)) {
+ // previously focused item still has focus and is off screen, give
+ // it up (take it back to ourselves)
+ // (also, need to temporarily force FOCUS_BEFORE_DESCENDANTS so we are
+ // sure to
+ // get it)
+ final int descendantFocusability = getDescendantFocusability(); // save
+ setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
+ requestFocus();
+ setDescendantFocusability(descendantFocusability); // restore
+ }
+ return true;
+ }
+
+ /**
+ * @return whether the descendant of this scroll view is scrolled off
+ * screen.
+ */
+ private boolean isOffScreen(View descendant) {
+ return !isWithinDeltaOfScreen(descendant, 0, getHeight());
+ }
+
+ /**
+ * @return whether the descendant of this scroll view is within delta
+ * pixels of being on the screen.
+ */
+ private boolean isWithinDeltaOfScreen(View descendant, int delta, int height) {
+ descendant.getDrawingRect(mTempRect);
+ offsetDescendantRectToMyCoords(descendant, mTempRect);
+
+ return (mTempRect.bottom + delta) >= getScrollY()
+ && (mTempRect.top - delta) <= (getScrollY() + height);
+ }
+
+ /**
+ * Smooth scroll by a Y delta
+ *
+ * @param delta the number of pixels to scroll by on the Y axis
+ */
+ private void doScrollY(int delta) {
+ if (delta != 0) {
+ if (mSmoothScrollingEnabled) {
+ smoothScrollBy(0, delta);
+ } else {
+ scrollBy(0, delta);
+ }
+ }
+ }
+
+ /**
+ * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
+ *
+ * @param dx the number of pixels to scroll by on the X axis
+ * @param dy the number of pixels to scroll by on the Y axis
+ */
+ public final void smoothScrollBy(int dx, int dy) {
+ smoothScrollBy(dx, dy, DEFAULT_SMOOTH_SCROLL_DURATION, false);
+ }
+
+ /**
+ * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
+ *
+ * @param dx the number of pixels to scroll by on the X axis
+ * @param dy the number of pixels to scroll by on the Y axis
+ * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds
+ */
+ public final void smoothScrollBy(int dx, int dy, int scrollDurationMs) {
+ smoothScrollBy(dx, dy, scrollDurationMs, false);
+ }
+
+ /**
+ * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
+ *
+ * @param dx the number of pixels to scroll by on the X axis
+ * @param dy the number of pixels to scroll by on the Y axis
+ * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds
+ * @param withNestedScrolling whether to include nested scrolling operations.
+ */
+ private void smoothScrollBy(int dx, int dy, int scrollDurationMs, boolean withNestedScrolling) {
+ if (getChildCount() == 0) {
+ // Nothing to do.
+ return;
+ }
+ long duration = AnimationUtils.currentAnimationTimeMillis() - mLastScroll;
+ if (duration > ANIMATED_SCROLL_GAP) {
+ View child = getChildAt(0);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin;
+ int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom();
+ final int scrollY = getScrollY();
+ final int maxY = Math.max(0, childSize - parentSpace);
+ dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY;
+ mScroller.startScroll(getScrollX(), scrollY, 0, dy, scrollDurationMs);
+ runAnimatedScroll(withNestedScrolling);
+ } else {
+ if (!mScroller.isFinished()) {
+ abortAnimatedScroll();
+ }
+ scrollBy(dx, dy);
+ }
+ mLastScroll = AnimationUtils.currentAnimationTimeMillis();
+ }
+
+ /**
+ * Like {@link #scrollTo}, but scroll smoothly instead of immediately.
+ *
+ * @param x the position where to scroll on the X axis
+ * @param y the position where to scroll on the Y axis
+ */
+ public final void smoothScrollTo(int x, int y) {
+ smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, false);
+ }
+
+ /**
+ * Like {@link #scrollTo}, but scroll smoothly instead of immediately.
+ *
+ * @param x the position where to scroll on the X axis
+ * @param y the position where to scroll on the Y axis
+ * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds
+ */
+ public final void smoothScrollTo(int x, int y, int scrollDurationMs) {
+ smoothScrollTo(x, y, scrollDurationMs, false);
+ }
+
+ /**
+ * Like {@link #scrollTo}, but scroll smoothly instead of immediately.
+ *
+ * @param x the position where to scroll on the X axis
+ * @param y the position where to scroll on the Y axis
+ * @param withNestedScrolling whether to include nested scrolling operations.
+ */
+ // This should be considered private, it is package private to avoid a synthetic ancestor.
+ @SuppressWarnings("SameParameterValue")
+ void smoothScrollTo(int x, int y, boolean withNestedScrolling) {
+ smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, withNestedScrolling);
+ }
+
+ /**
+ * Like {@link #scrollTo}, but scroll smoothly instead of immediately.
+ *
+ * @param x the position where to scroll on the X axis
+ * @param y the position where to scroll on the Y axis
+ * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds
+ * @param withNestedScrolling whether to include nested scrolling operations.
+ */
+ // This should be considered private, it is package private to avoid a synthetic ancestor.
+ void smoothScrollTo(int x, int y, int scrollDurationMs, boolean withNestedScrolling) {
+ smoothScrollBy(x - getScrollX(), y - getScrollY(), scrollDurationMs, withNestedScrolling);
+ }
+
+ /**
+ * <p>The scroll range of a scroll view is the overall height of all of its
+ * children.</p>
+ */
+ @Override
+ public int computeVerticalScrollRange() {
+ final int count = getChildCount();
+ final int parentSpace = getHeight() - getPaddingBottom() - getPaddingTop();
+ if (count == 0) {
+ return parentSpace;
+ }
+
+ View child = getChildAt(0);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int scrollRange = child.getBottom() + lp.bottomMargin;
+ final int scrollY = getScrollY();
+ final int overscrollBottom = Math.max(0, scrollRange - parentSpace);
+ if (scrollY < 0) {
+ scrollRange -= scrollY;
+ } else if (scrollY > overscrollBottom) {
+ scrollRange += scrollY - overscrollBottom;
+ }
+
+ return scrollRange;
+ }
+
+ @Override
+ public int computeVerticalScrollOffset() {
+ return Math.max(0, super.computeVerticalScrollOffset());
+ }
+
+ @Override
+ public int computeVerticalScrollExtent() {
+ return super.computeVerticalScrollExtent();
+ }
+
+ @Override
+ public int computeHorizontalScrollRange() {
+ return super.computeHorizontalScrollRange();
+ }
+
+ @Override
+ public int computeHorizontalScrollOffset() {
+ return super.computeHorizontalScrollOffset();
+ }
+
+ @Override
+ public int computeHorizontalScrollExtent() {
+ return super.computeHorizontalScrollExtent();
+ }
+
+ @Override
+ protected void measureChild(@NonNull View child, int parentWidthMeasureSpec,
+ int parentHeightMeasureSpec) {
+ ViewGroup.LayoutParams lp = child.getLayoutParams();
+
+ int childWidthMeasureSpec;
+ int childHeightMeasureSpec;
+
+ childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, getPaddingLeft()
+ + getPaddingRight(), lp.width);
+
+ childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+
+ child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
+ }
+
+ @Override
+ protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
+ int parentHeightMeasureSpec, int heightUsed) {
+ final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
+
+ final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
+ getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
+ + widthUsed, lp.width);
+ final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
+ lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);
+
+ child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
+ }
+
+ @Override
+ public void computeScroll() {
+
+ if (mScroller.isFinished()) {
+ return;
+ }
+
+ mScroller.computeScrollOffset();
+ final int y = mScroller.getCurrY();
+ int unconsumed = consumeFlingInVerticalStretch(y - mLastScrollerY);
+ mLastScrollerY = y;
+
+ // Nested Scrolling Pre Pass
+ mScrollConsumed[1] = 0;
+ dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
+ ViewCompat.TYPE_NON_TOUCH);
+ unconsumed -= mScrollConsumed[1];
+
+ final int range = getScrollRange();
+
+ if (unconsumed != 0) {
+ // Internal Scroll
+ final int oldScrollY = getScrollY();
+ overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
+ final int scrolledByMe = getScrollY() - oldScrollY;
+ unconsumed -= scrolledByMe;
+
+ // Nested Scrolling Post Pass
+ mScrollConsumed[1] = 0;
+ dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
+ ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
+ unconsumed -= mScrollConsumed[1];
+ }
+
+ if (unconsumed != 0) {
+ final int mode = getOverScrollMode();
+ final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
+ || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
+ if (canOverscroll) {
+ if (unconsumed < 0) {
+ if (mEdgeGlowTop.isFinished()) {
+ mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
+ }
+ } else {
+ if (mEdgeGlowBottom.isFinished()) {
+ mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
+ }
+ }
+ }
+ abortAnimatedScroll();
+ }
+
+ if (!mScroller.isFinished()) {
+ postInvalidateOnAnimation();
+ } else {
+ stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
+ }
+ }
+
+ /**
+ * If either of the vertical edge glows are currently active, this consumes part or all of
+ * deltaY on the edge glow.
+ *
+ * @param deltaY The pointer motion, in pixels, in the vertical direction, positive
+ * for moving down and negative for moving up.
+ * @param x The vertical position of the pointer.
+ * @return The amount of <code>deltaY</code> that has been consumed by the
+ * edge glow.
+ */
+ private int releaseVerticalGlow(int deltaY, float x) {
+ // First allow releasing existing overscroll effect:
+ float consumed = 0;
+ float displacement = x / getWidth();
+ float pullDistance = (float) deltaY / getHeight();
+ if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) {
+ consumed = -EdgeEffectCompat.onPullDistance(mEdgeGlowTop, -pullDistance, displacement);
+ if (EdgeEffectCompat.getDistance(mEdgeGlowTop) == 0) {
+ mEdgeGlowTop.onRelease();
+ }
+ } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) {
+ consumed = EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, pullDistance,
+ 1 - displacement);
+ if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) == 0) {
+ mEdgeGlowBottom.onRelease();
+ }
+ }
+ int pixelsConsumed = Math.round(consumed * getHeight());
+ if (pixelsConsumed != 0) {
+ invalidate();
+ }
+ return pixelsConsumed;
+ }
+
+ private void runAnimatedScroll(boolean participateInNestedScrolling) {
+ if (participateInNestedScrolling) {
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
+ } else {
+ stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
+ }
+ mLastScrollerY = getScrollY();
+ postInvalidateOnAnimation();
+ }
+
+ private void abortAnimatedScroll() {
+ mScroller.abortAnimation();
+ stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
+ }
+
+ /**
+ * Scrolls the view to the given child.
+ *
+ * @param child the View to scroll to
+ */
+ private void scrollToChild(View child) {
+ child.getDrawingRect(mTempRect);
+
+ /* Offset from child's local coordinates to ScrollView coordinates */
+ offsetDescendantRectToMyCoords(child, mTempRect);
+
+ int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
+
+ if (scrollDelta != 0) {
+ scrollBy(0, scrollDelta);
+ }
+ }
+
+ /**
+ * If rect is off screen, scroll just enough to get it (or at least the
+ * first screen size chunk of it) on screen.
+ *
+ * @param rect The rectangle.
+ * @param immediate True to scroll immediately without animation
+ * @return true if scrolling was performed
+ */
+ private boolean scrollToChildRect(Rect rect, boolean immediate) {
+ final int delta = computeScrollDeltaToGetChildRectOnScreen(rect);
+ final boolean scroll = delta != 0;
+ if (scroll) {
+ if (immediate) {
+ scrollBy(0, delta);
+ } else {
+ smoothScrollBy(0, delta);
+ }
+ }
+ return scroll;
+ }
+
+ /**
+ * Compute the amount to scroll in the Y direction in order to get
+ * a rectangle completely on the screen (or, if taller than the screen,
+ * at least the first screen size chunk of it).
+ *
+ * @param rect The rect.
+ * @return The scroll delta.
+ */
+ protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
+ if (getChildCount() == 0) return 0;
+
+ int height = getHeight();
+ int screenTop = getScrollY();
+ int screenBottom = screenTop + height;
+ int actualScreenBottom = screenBottom;
+
+ int fadingEdge = getVerticalFadingEdgeLength();
+
+ // TODO: screenTop should be incremented by fadingEdge * getTopFadingEdgeStrength (but for
+ // the target scroll distance).
+ // leave room for top fading edge as long as rect isn't at very top
+ if (rect.top > 0) {
+ screenTop += fadingEdge;
+ }
+
+ // TODO: screenBottom should be decremented by fadingEdge * getBottomFadingEdgeStrength (but
+ // for the target scroll distance).
+ // leave room for bottom fading edge as long as rect isn't at very bottom
+ View child = getChildAt(0);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (rect.bottom < child.getHeight() + lp.topMargin + lp.bottomMargin) {
+ screenBottom -= fadingEdge;
+ }
+
+ int scrollYDelta = 0;
+
+ if (rect.bottom > screenBottom && rect.top > screenTop) {
+ // need to move down to get it in view: move down just enough so
+ // that the entire rectangle is in view (or at least the first
+ // screen size chunk).
+
+ if (rect.height() > height) {
+ // just enough to get screen size chunk on
+ scrollYDelta += (rect.top - screenTop);
+ } else {
+ // get entire rect at bottom of screen
+ scrollYDelta += (rect.bottom - screenBottom);
+ }
+
+ // make sure we aren't scrolling beyond the end of our content
+ int bottom = child.getBottom() + lp.bottomMargin;
+ int distanceToBottom = bottom - actualScreenBottom;
+ scrollYDelta = Math.min(scrollYDelta, distanceToBottom);
+
+ } else if (rect.top < screenTop && rect.bottom < screenBottom) {
+ // need to move up to get it in view: move up just enough so that
+ // entire rectangle is in view (or at least the first screen
+ // size chunk of it).
+
+ if (rect.height() > height) {
+ // screen size chunk
+ scrollYDelta -= (screenBottom - rect.bottom);
+ } else {
+ // entire rect at top
+ scrollYDelta -= (screenTop - rect.top);
+ }
+
+ // make sure we aren't scrolling any further than the top our content
+ scrollYDelta = Math.max(scrollYDelta, -getScrollY());
+ }
+ return scrollYDelta;
+ }
+
+ @Override
+ public void requestChildFocus(View child, View focused) {
+ onRequestChildFocus(child, focused);
+ super.requestChildFocus(child, focused);
+ }
+
+ protected void onRequestChildFocus(View child, View focused) {
+ if (!mIsLayoutDirty) {
+ scrollToChild(focused);
+ } else {
+ // The child may not be laid out yet, we can't compute the scroll yet
+ mChildToScrollTo = focused;
+ }
+ }
+
+
+ /**
+ * When looking for focus in children of a scroll view, need to be a little
+ * more careful not to give focus to something that is scrolled off screen.
+ *
+ * This is more expensive than the default {@link ViewGroup}
+ * implementation, otherwise this behavior might have been made the default.
+ */
+ @Override
+ protected boolean onRequestFocusInDescendants(int direction,
+ Rect previouslyFocusedRect) {
+
+ // convert from forward / backward notation to up / down / left / right
+ // (ugh).
+ if (direction == View.FOCUS_FORWARD) {
+ direction = View.FOCUS_DOWN;
+ } else if (direction == View.FOCUS_BACKWARD) {
+ direction = View.FOCUS_UP;
+ }
+
+ final View nextFocus = previouslyFocusedRect == null
+ ? FocusFinder.getInstance().findNextFocus(this, null, direction)
+ : FocusFinder.getInstance().findNextFocusFromRect(
+ this, previouslyFocusedRect, direction);
+
+ if (nextFocus == null) {
+ return false;
+ }
+
+ if (isOffScreen(nextFocus)) {
+ return false;
+ }
+
+ return nextFocus.requestFocus(direction, previouslyFocusedRect);
+ }
+
+ @Override
+ public boolean requestChildRectangleOnScreen(@NonNull View child, Rect rectangle,
+ boolean immediate) {
+ // offset into coordinate space of this scroll view
+ rectangle.offset(child.getLeft() - child.getScrollX(),
+ child.getTop() - child.getScrollY());
+
+ return scrollToChildRect(rectangle, immediate);
+ }
+
+ @Override
+ public void requestLayout() {
+ mIsLayoutDirty = true;
+ super.requestLayout();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ mIsLayoutDirty = false;
+ // Give a child focus if it needs it
+ if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) {
+ scrollToChild(mChildToScrollTo);
+ }
+ mChildToScrollTo = null;
+
+ if (!mIsLaidOut) {
+ // If there is a saved state, scroll to the position saved in that state.
+ if (mSavedState != null) {
+ scrollTo(getScrollX(), mSavedState.scrollPosition);
+ mSavedState = null;
+ } // mScrollY default value is "0"
+
+ // Make sure current scrollY position falls into the scroll range. If it doesn't,
+ // scroll such that it does.
+ int childSize = 0;
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ childSize = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
+ }
+ int parentSpace = b - t - getPaddingTop() - getPaddingBottom();
+ int currentScrollY = getScrollY();
+ int newScrollY = clamp(currentScrollY, parentSpace, childSize);
+ if (newScrollY != currentScrollY) {
+ scrollTo(getScrollX(), newScrollY);
+ }
+ }
+
+ // Calling this with the present values causes it to re-claim them
+ scrollTo(getScrollX(), getScrollY());
+ mIsLaidOut = true;
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ mIsLaidOut = false;
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ View currentFocused = findFocus();
+ if (null == currentFocused || this == currentFocused) {
+ return;
+ }
+
+ // If the currently-focused view was visible on the screen when the
+ // screen was at the old height, then scroll the screen to make that
+ // view visible with the new screen height.
+ if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) {
+ currentFocused.getDrawingRect(mTempRect);
+ offsetDescendantRectToMyCoords(currentFocused, mTempRect);
+ int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
+ doScrollY(scrollDelta);
+ }
+ }
+
+ /**
+ * Return true if child is a descendant of parent, (or equal to the parent).
+ */
+ private static boolean isViewDescendantOf(View child, View parent) {
+ if (child == parent) {
+ return true;
+ }
+
+ final ViewParent theParent = child.getParent();
+ return (theParent instanceof ViewGroup) && isViewDescendantOf((View) theParent, parent);
+ }
+
+ /**
+ * Fling the scroll view
+ *
+ * @param velocityY The initial velocity in the Y direction. Positive
+ * numbers mean that the finger/cursor is moving down the screen,
+ * which means we want to scroll towards the top.
+ */
+ public void fling(int velocityY) {
+ if (getChildCount() > 0) {
+
+ mScroller.fling(getScrollX(), getScrollY(), // start
+ 0, velocityY, // velocities
+ 0, 0, // x
+ Integer.MIN_VALUE, Integer.MAX_VALUE, // y
+ 0, 0); // overscroll
+ runAnimatedScroll(true);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>This version also clamps the scrolling to the bounds of our child.
+ */
+ @Override
+ public void scrollTo(int x, int y) {
+ // we rely on the fact the View.scrollBy calls scrollTo.
+ if (getChildCount() > 0) {
+ View child = getChildAt(0);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ int parentSpaceHorizontal = getWidth() - getPaddingLeft() - getPaddingRight();
+ int childSizeHorizontal = child.getWidth() + lp.leftMargin + lp.rightMargin;
+ int parentSpaceVertical = getHeight() - getPaddingTop() - getPaddingBottom();
+ int childSizeVertical = child.getHeight() + lp.topMargin + lp.bottomMargin;
+ x = clamp(x, parentSpaceHorizontal, childSizeHorizontal);
+ y = clamp(y, parentSpaceVertical, childSizeVertical);
+ if (x != getScrollX() || y != getScrollY()) {
+ super.scrollTo(x, y);
+ }
+ }
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ super.draw(canvas);
+ final int scrollY = getScrollY();
+ if (!mEdgeGlowTop.isFinished()) {
+ final int restoreCount = canvas.save();
+ int width = getWidth();
+ int height = getHeight();
+ int xTranslation = 0;
+ int yTranslation = Math.min(0, scrollY);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
+ || Api21Impl.getClipToPadding(this)) {
+ width -= getPaddingLeft() + getPaddingRight();
+ xTranslation += getPaddingLeft();
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+ && Api21Impl.getClipToPadding(this)) {
+ height -= getPaddingTop() + getPaddingBottom();
+ yTranslation += getPaddingTop();
+ }
+ canvas.translate(xTranslation, yTranslation);
+ mEdgeGlowTop.setSize(width, height);
+ if (mEdgeGlowTop.draw(canvas)) {
+ postInvalidateOnAnimation();
+ }
+ canvas.restoreToCount(restoreCount);
+ }
+ if (!mEdgeGlowBottom.isFinished()) {
+ final int restoreCount = canvas.save();
+ int width = getWidth();
+ int height = getHeight();
+ int xTranslation = 0;
+ int yTranslation = Math.max(getScrollRange(), scrollY) + height;
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
+ || Api21Impl.getClipToPadding(this)) {
+ width -= getPaddingLeft() + getPaddingRight();
+ xTranslation += getPaddingLeft();
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+ && Api21Impl.getClipToPadding(this)) {
+ height -= getPaddingTop() + getPaddingBottom();
+ yTranslation -= getPaddingBottom();
+ }
+ canvas.translate(xTranslation - width, yTranslation);
+ canvas.rotate(180, width, 0);
+ mEdgeGlowBottom.setSize(width, height);
+ if (mEdgeGlowBottom.draw(canvas)) {
+ postInvalidateOnAnimation();
+ }
+ canvas.restoreToCount(restoreCount);
+ }
+ }
+
+ private static int clamp(int n, int my, int child) {
+ if (my >= child || n < 0) {
+ /* my >= child is this case:
+ * |--------------- me ---------------|
+ * |------ child ------|
+ * or
+ * |--------------- me ---------------|
+ * |------ child ------|
+ * or
+ * |--------------- me ---------------|
+ * |------ child ------|
+ *
+ * n < 0 is this case:
+ * |------ me ------|
+ * |-------- child --------|
+ * |-- mScrollX --|
+ */
+ return 0;
+ }
+ if ((my + n) > child) {
+ /* this case:
+ * |------ me ------|
+ * |------ child ------|
+ * |-- mScrollX --|
+ */
+ return child - my;
+ }
+ return n;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Parcelable state) {
+ if (!(state instanceof SavedState)) {
+ super.onRestoreInstanceState(state);
+ return;
+ }
+
+ SavedState ss = (SavedState) state;
+ super.onRestoreInstanceState(ss.getSuperState());
+ mSavedState = ss;
+ requestLayout();
+ }
+
+ @NonNull
+ @Override
+ protected Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ SavedState ss = new SavedState(superState);
+ ss.scrollPosition = getScrollY();
+ return ss;
+ }
+
+ static class SavedState extends BaseSavedState {
+ public int scrollPosition;
+
+ SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ SavedState(Parcel source) {
+ super(source);
+ scrollPosition = source.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeInt(scrollPosition);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "HorizontalScrollView.SavedState{"
+ + Integer.toHexString(System.identityHashCode(this))
+ + " scrollPosition=" + scrollPosition + "}";
+ }
+
+ public static final Creator<SavedState> CREATOR =
+ new Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+
+ static class AccessibilityDelegate extends AccessibilityDelegateCompat {
+ @Override
+ public boolean performAccessibilityAction(View host, int action, Bundle arguments) {
+ if (super.performAccessibilityAction(host, action, arguments)) {
+ return true;
+ }
+ final NestedScrollView nsvHost = (NestedScrollView) host;
+ if (!nsvHost.isEnabled()) {
+ return false;
+ }
+ int height = nsvHost.getHeight();
+ Rect rect = new Rect();
+ // Gets the visible rect on the screen except for the rotation or scale cases which
+ // might affect the result.
+ if (nsvHost.getMatrix().isIdentity() && nsvHost.getGlobalVisibleRect(rect)) {
+ height = rect.height();
+ }
+ switch (action) {
+ case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD:
+ case android.R.id.accessibilityActionScrollDown: {
+ final int viewportHeight = height - nsvHost.getPaddingBottom()
+ - nsvHost.getPaddingTop();
+ final int targetScrollY = Math.min(nsvHost.getScrollY() + viewportHeight,
+ nsvHost.getScrollRange());
+ if (targetScrollY != nsvHost.getScrollY()) {
+ nsvHost.smoothScrollTo(0, targetScrollY, true);
+ return true;
+ }
+ }
+ return false;
+ case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD:
+ case android.R.id.accessibilityActionScrollUp: {
+ final int viewportHeight = height - nsvHost.getPaddingBottom()
+ - nsvHost.getPaddingTop();
+ final int targetScrollY = Math.max(nsvHost.getScrollY() - viewportHeight, 0);
+ if (targetScrollY != nsvHost.getScrollY()) {
+ nsvHost.smoothScrollTo(0, targetScrollY, true);
+ return true;
+ }
+ }
+ return false;
+ }
+ return false;
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ final NestedScrollView nsvHost = (NestedScrollView) host;
+ info.setClassName(ScrollView.class.getName());
+ if (nsvHost.isEnabled()) {
+ final int scrollRange = nsvHost.getScrollRange();
+ if (scrollRange > 0) {
+ info.setScrollable(true);
+ if (nsvHost.getScrollY() > 0) {
+ info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat
+ .ACTION_SCROLL_BACKWARD);
+ info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat
+ .ACTION_SCROLL_UP);
+ }
+ if (nsvHost.getScrollY() < scrollRange) {
+ info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat
+ .ACTION_SCROLL_FORWARD);
+ info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat
+ .ACTION_SCROLL_DOWN);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(host, event);
+ final NestedScrollView nsvHost = (NestedScrollView) host;
+ event.setClassName(ScrollView.class.getName());
+ final boolean scrollable = nsvHost.getScrollRange() > 0;
+ event.setScrollable(scrollable);
+ event.setScrollX(nsvHost.getScrollX());
+ event.setScrollY(nsvHost.getScrollY());
+ AccessibilityRecordCompat.setMaxScrollX(event, nsvHost.getScrollX());
+ AccessibilityRecordCompat.setMaxScrollY(event, nsvHost.getScrollRange());
+ }
+ }
+
+ class DifferentialMotionFlingTargetImpl implements DifferentialMotionFlingTarget {
+ @Override
+ public boolean startDifferentialMotionFling(float velocity) {
+ if (velocity == 0) {
+ return false;
+ }
+ stopDifferentialMotionFling();
+ fling((int) velocity);
+ return true;
+ }
+
+ @Override
+ public void stopDifferentialMotionFling() {
+ mScroller.abortAnimation();
+ }
+
+ @Override
+ public float getScaledScrollFactor() {
+ return -getVerticalScrollFactorCompat();
+ }
+ }
+
+ @RequiresApi(21)
+ static class Api21Impl {
+ private Api21Impl() {
+ // This class is not instantiable.
+ }
+
+ @DoNotInline
+ static boolean getClipToPadding(ViewGroup viewGroup) {
+ return viewGroup.getClipToPadding();
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch
new file mode 100644
index 00000000..913d3b1a
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch
@@ -0,0 +1,103 @@
+--- prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar!/androidx/core/widget/NestedScrollView.java 1980-02-01 00:00:00.000000000 -0800
++++ packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/NestedScrollView.java 2024-03-04 17:17:47.357059016 -0800
+@@ -1,5 +1,5 @@
+ /*
+- * Copyright (C) 2015 The Android Open Source Project
++ * Copyright 2024 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.
+@@ -15,10 +15,9 @@
+ */
+
+
+-package androidx.core.widget;
++package com.android.intentresolver.widget;
+
+ import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+-import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+ import android.content.Context;
+ import android.content.res.TypedArray;
+@@ -67,13 +66,19 @@
+ import androidx.core.view.ViewCompat;
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+ import androidx.core.view.accessibility.AccessibilityRecordCompat;
++import androidx.core.widget.EdgeEffectCompat;
+
+ import java.util.List;
+
+ /**
+- * NestedScrollView is just like {@link ScrollView}, but it supports acting
+- * as both a nested scrolling parent and child on both new and old versions of Android.
+- * Nested scrolling is enabled by default.
++ * A copy of the {@link androidx.core.widget.NestedScrollView} (from
++ * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar)
++ * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}:
++ * the method's body is extracted into the new protected method,
++ * {@link #onRequestChildFocus(View, View)}.
++ * <p>
++ * For the exact change see NestedScrollView.java.patch file.
++ * </p>
+ */
+ public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
+ NestedScrollingChild3, ScrollingView {
+@@ -1858,7 +1863,6 @@
+ * <p>The scroll range of a scroll view is the overall height of all of its
+ * children.</p>
+ */
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeVerticalScrollRange() {
+ final int count = getChildCount();
+@@ -1881,31 +1885,26 @@
+ return scrollRange;
+ }
+
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeVerticalScrollOffset() {
+ return Math.max(0, super.computeVerticalScrollOffset());
+ }
+
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeVerticalScrollExtent() {
+ return super.computeVerticalScrollExtent();
+ }
+
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeHorizontalScrollRange() {
+ return super.computeHorizontalScrollRange();
+ }
+
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeHorizontalScrollOffset() {
+ return super.computeHorizontalScrollOffset();
+ }
+
+- @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @Override
+ public int computeHorizontalScrollExtent() {
+ return super.computeHorizontalScrollExtent();
+@@ -2163,13 +2162,17 @@
+
+ @Override
+ public void requestChildFocus(View child, View focused) {
++ onRequestChildFocus(child, focused);
++ super.requestChildFocus(child, focused);
++ }
++
++ protected void onRequestChildFocus(View child, View focused) {
+ if (!mIsLayoutDirty) {
+ scrollToChild(focused);
+ } else {
+ // The child may not be laid out yet, we can't compute the scroll yet
+ mChildToScrollTo = focused;
+ }
+- super.requestChildFocus(child, focused);
+ }
+
+
diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
index a7906001..a8aa633b 100644
--- a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
+++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
@@ -26,10 +26,10 @@ internal val RecyclerView.areAllChildrenVisible: Boolean
val first = getChildAt(0)
val last = getChildAt(count - 1)
val itemCount = adapter?.itemCount ?: 0
- return getChildAdapterPosition(first) == 0
- && getChildAdapterPosition(last) == itemCount - 1
- && isFullyVisible(first)
- && isFullyVisible(last)
+ return getChildAdapterPosition(first) == 0 &&
+ getChildAdapterPosition(last) == itemCount - 1 &&
+ isFullyVisible(first) &&
+ isFullyVisible(last)
}
private fun RecyclerView.isFullyVisible(view: View): Boolean =
diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java
index de76a1d2..4895a2cd 100644
--- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java
+++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java
@@ -19,7 +19,6 @@ package com.android.intentresolver.widget;
import static android.content.res.Resources.ID_NULL;
-import android.annotation.IdRes;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
@@ -45,6 +44,10 @@ import android.view.animation.AnimationUtils;
import android.widget.AbsListView;
import android.widget.OverScroller;
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.view.ScrollingView;
import androidx.recyclerview.widget.RecyclerView;
import com.android.intentresolver.R;
@@ -58,7 +61,7 @@ public class ResolverDrawerLayout extends ViewGroup {
/**
* Max width of the whole drawer layout
*/
- private final int mMaxWidth;
+ private int mMaxWidth;
/**
* Max total visible height of views not marked always-show when in the closed/initial state
@@ -131,6 +134,9 @@ public class ResolverDrawerLayout extends ViewGroup {
private AbsListView mNestedListChild;
private RecyclerView mNestedRecyclerChild;
+ @Nullable
+ private final ScrollablePreviewFlingLogicDelegate mFlingLogicDelegate;
+
private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener =
new ViewTreeObserver.OnTouchModeChangeListener() {
@Override
@@ -167,6 +173,12 @@ public class ResolverDrawerLayout extends ViewGroup {
mIgnoreOffsetTopLimitViewId = a.getResourceId(
R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL);
}
+ mFlingLogicDelegate =
+ a.getBoolean(
+ R.styleable.ResolverDrawerLayout_useScrollablePreviewNestedFlingLogic,
+ false)
+ ? new ScrollablePreviewFlingLogicDelegate() {}
+ : null;
a.recycle();
mScrollIndicatorDrawable = mContext.getDrawable(
@@ -252,10 +264,24 @@ public class ResolverDrawerLayout extends ViewGroup {
invalidate();
}
+ /**
+ * Sets max drawer width.
+ */
+ public void setMaxWidth(int maxWidth) {
+ if (mMaxWidth != maxWidth) {
+ mMaxWidth = maxWidth;
+ requestLayout();
+ }
+ }
+
public void setDismissLocked(boolean locked) {
mDismissLocked = locked;
}
+ int getTopOffset() {
+ return mTopOffset;
+ }
+
private boolean isMoving() {
return mIsDragging || !mScroller.isFinished();
}
@@ -832,6 +858,9 @@ public class ResolverDrawerLayout extends ViewGroup {
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
+ if (mFlingLogicDelegate != null) {
+ return mFlingLogicDelegate.onNestedPreFling(this, target, velocityX, velocityY);
+ }
if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) {
smoothScrollTo(0, velocityY);
return true;
@@ -841,9 +870,12 @@ public class ResolverDrawerLayout extends ViewGroup {
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
+ if (mFlingLogicDelegate != null) {
+ return mFlingLogicDelegate.onNestedFling(this, target, velocityX, velocityY, consumed);
+ }
// TODO: find a more suitable way to fix it.
// RecyclerView started reporting `consumed` as true whenever a scrolling is enabled,
- // previously the value was based whether the fling can be performed in given direction
+ // previously the value was based on whether the fling can be performed in given direction
// i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop method is a
// workaround that restores the legacy functionality.
boolean shouldConsume = (Math.abs(velocityY) > mMinFlingVelocity)
@@ -885,6 +917,13 @@ public class ResolverDrawerLayout extends ViewGroup {
&& firstChild.getTop() >= recyclerView.getPaddingTop();
}
+ private static boolean isFlingTargetAtTop(View target) {
+ if (target instanceof ScrollingView) {
+ return !target.canScrollVertically(-1);
+ }
+ return false;
+ }
+
private boolean performAccessibilityActionCommon(int action) {
switch (action) {
case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
@@ -974,7 +1013,7 @@ public class ResolverDrawerLayout extends ViewGroup {
}
@Override
- public void onDrawForeground(Canvas canvas) {
+ public void onDrawForeground(@NonNull Canvas canvas) {
if (mScrollIndicatorDrawable != null) {
mScrollIndicatorDrawable.draw(canvas);
}
@@ -1299,4 +1338,74 @@ public class ResolverDrawerLayout extends ViewGroup {
}
return mMetricsLogger;
}
+
+ /**
+ * Controlled by
+ * {@link com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW}
+ */
+ private interface ScrollablePreviewFlingLogicDelegate {
+ default boolean onNestedPreFling(
+ ResolverDrawerLayout drawer, View target, float velocityX, float velocityY) {
+ boolean shouldScroll = !drawer.getShowAtTop() && velocityY > drawer.mMinFlingVelocity
+ && drawer.mCollapseOffset != 0;
+ if (shouldScroll) {
+ drawer.smoothScrollTo(0, velocityY);
+ return true;
+ }
+ boolean shouldDismiss = (Math.abs(velocityY) > drawer.mMinFlingVelocity)
+ && velocityY < 0
+ && isFlingTargetAtTop(target);
+ if (shouldDismiss) {
+ if (drawer.getShowAtTop()) {
+ drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY);
+ } else {
+ if (drawer.isDismissable()
+ && drawer.mCollapseOffset > drawer.mCollapsibleHeight) {
+ drawer.smoothScrollTo(drawer.mHeightUsed, velocityY);
+ drawer.mDismissOnScrollerFinished = true;
+ } else {
+ drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY);
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ default boolean onNestedFling(
+ ResolverDrawerLayout drawer,
+ View target,
+ float velocityX,
+ float velocityY,
+ boolean consumed) {
+ // TODO: find a more suitable way to fix it.
+ // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled,
+ // previously the value was based on whether the fling can be performed in given
+ // direction i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop
+ // method is a workaround that restores the legacy functionality.
+ boolean shouldConsume = (Math.abs(velocityY) > drawer.mMinFlingVelocity) && !consumed;
+ if (shouldConsume) {
+ if (drawer.getShowAtTop()) {
+ if (drawer.isDismissable() && velocityY > 0) {
+ drawer.abortAnimation();
+ drawer.dismiss();
+ } else {
+ drawer.smoothScrollTo(
+ velocityY < 0 ? drawer.mCollapsibleHeight : 0, velocityY);
+ }
+ } else {
+ if (drawer.isDismissable()
+ && velocityY < 0
+ && drawer.mCollapseOffset > drawer.mCollapsibleHeight) {
+ drawer.smoothScrollTo(drawer.mHeightUsed, velocityY);
+ drawer.mDismissOnScrollerFinished = true;
+ } else {
+ drawer.smoothScrollTo(
+ velocityY > 0 ? 0 : drawer.mCollapsibleHeight, velocityY);
+ }
+ }
+ }
+ return shouldConsume;
+ }
+ }
}
diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt b/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt
new file mode 100644
index 00000000..0c537a12
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.
+ */
+
+@file:JvmName("ResolverDrawerLayoutExt")
+
+package com.android.intentresolver.widget
+
+import android.graphics.Rect
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+
+fun ResolverDrawerLayout.getVisibleDrawerRect(outRect: Rect) {
+ if (!isLaidOut) {
+ outRect.set(0, 0, 0, 0)
+ return
+ }
+ val firstChild = firstNonGoneChild()
+ val lp = firstChild?.layoutParams as? MarginLayoutParams
+ val margin = lp?.topMargin ?: 0
+ val top = maxOf(paddingTop, topOffset + margin)
+ val leftEdge = paddingLeft
+ val rightEdge = width - paddingRight
+ val widthAvailable = rightEdge - leftEdge
+ val childWidth = firstChild?.width ?: 0
+ val left = leftEdge + (widthAvailable - childWidth) / 2
+ val right = left + childWidth
+ outRect.set(left, top, right, height - paddingBottom)
+}
+
+fun ResolverDrawerLayout.firstNonGoneChild(): View? {
+ for (i in 0 until childCount) {
+ val view = getChildAt(i)
+ if (view.visibility != View.GONE) {
+ return view
+ }
+ }
+ return null
+}
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
index 3bbafc40..935a8724 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -22,15 +22,21 @@ import android.graphics.Rect
import android.net.Uri
import android.util.AttributeSet
import android.util.PluralsMessageFormatter
+import android.util.Size
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.animation.AlphaAnimation
+import android.view.animation.Animation
+import android.view.animation.Animation.AnimationListener
+import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
+import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.intentresolver.R
@@ -45,6 +51,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
private const val TRANSITION_NAME = "screenshot_preview_image"
private const val PLURALS_COUNT = "count"
@@ -54,55 +61,63 @@ private const val MIN_ASPECT_RATIO_STRING = "2:5"
private const val MAX_ASPECT_RATIO = 2.5f
private const val MAX_ASPECT_RATIO_STRING = "5:2"
-private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap?
+private typealias CachingImageLoader = suspend (Uri, Size, Boolean) -> Bitmap?
class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
constructor(context: Context) : this(context, null)
+
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
constructor(
context: Context,
attrs: AttributeSet?,
- defStyleAttr: Int
+ defStyleAttr: Int,
) : super(context, attrs, defStyleAttr) {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
- adapter = Adapter(context)
+ val editButtonRoleDescription: CharSequence?
context
.obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0)
.use { a ->
var innerSpacing =
a.getDimensionPixelSize(
R.styleable.ScrollableImagePreviewView_itemInnerSpacing,
- -1
+ -1,
)
if (innerSpacing < 0) {
innerSpacing =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
3f,
- context.resources.displayMetrics
+ context.resources.displayMetrics,
)
.toInt()
}
outerSpacing =
a.getDimensionPixelSize(
R.styleable.ScrollableImagePreviewView_itemOuterSpacing,
- -1
+ -1,
)
if (outerSpacing < 0) {
outerSpacing =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
16f,
- context.resources.displayMetrics
+ context.resources.displayMetrics,
)
.toInt()
}
- addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
+ super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
maxWidthHint =
a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1)
+
+ editButtonRoleDescription =
+ a.getText(R.styleable.ScrollableImagePreviewView_editButtonRoleDescription)
}
+ val itemAnimator = ItemAnimator()
+ super.setItemAnimator(itemAnimator)
+ super.setAdapter(Adapter(context, itemAnimator.getAddDuration(), editButtonRoleDescription))
}
private var batchLoader: BatchPreviewLoader? = null
@@ -113,12 +128,18 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
* A hint about the maximum width this view can grow to, this helps to optimize preview loading.
*/
var maxWidthHint: Int = -1
- private var requestedHeight: Int = 0
+
private var isMeasured = false
private var maxAspectRatio = MAX_ASPECT_RATIO
private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING
private var outerSpacing: Int = 0
+ var previewHeight: Int
+ get() = previewAdapter.previewHeight
+ set(value) {
+ previewAdapter.previewHeight = value
+ }
+
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec)
if (!isMeasured) {
@@ -167,6 +188,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
return null
}
+ override fun setAdapter(adapter: RecyclerView.Adapter<*>?) {
+ error("This method is not supported")
+ }
+
+ override fun setItemAnimator(animator: RecyclerView.ItemAnimator?) {
+ error("This method is not supported")
+ }
+
fun setImageLoader(imageLoader: CachingImageLoader) {
previewAdapter.imageLoader = imageLoader
}
@@ -182,6 +211,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
BatchPreviewLoader(
previewAdapter.imageLoader ?: error("Image loader is not set"),
previews,
+ Size(previewHeight, previewHeight),
totalItemCount,
onUpdate = previewAdapter::addPreviews,
onCompletion = {
@@ -190,7 +220,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
onNoPreviewCallback?.run()
}
previewAdapter.markLoaded()
- }
+ },
)
maybeLoadAspectRatios()
}
@@ -254,22 +284,26 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
val type: PreviewType,
val uri: Uri,
val editAction: Runnable?,
- internal var aspectRatioString: String
+ internal var aspectRatioString: String,
) {
constructor(
type: PreviewType,
uri: Uri,
- editAction: Runnable?
+ editAction: Runnable?,
) : this(type, uri, editAction, "1:1")
}
enum class PreviewType {
Image,
Video,
- File
+ File,
}
- private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
+ private class Adapter(
+ private val context: Context,
+ private val fadeInDurationMs: Long,
+ private val editButtonRoleDescription: CharSequence?,
+ ) : RecyclerView.Adapter<ViewHolder>() {
private val previews = ArrayList<Preview>()
private val imagePreviewDescription =
context.resources.getString(R.string.image_preview_a11y_description)
@@ -284,11 +318,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
private var isLoading = false
private val hasOtherItem
get() = previews.size < totalItemCount
+
val hasPreviews: Boolean
get() = previews.isNotEmpty()
var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+ private var previewSize: Size = Size(0, 0)
+ var previewHeight: Int
+ get() = previewSize.height
+ set(value) {
+ previewSize = Size(value, value)
+ }
+
fun reset(totalItemCount: Int) {
firstImagePos = -1
previews.clear()
@@ -311,15 +353,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
if (newPreviews.isEmpty()) return
val insertPos = previews.size
val hadOtherItem = hasOtherItem
- val wasEmpty = previews.isEmpty()
+ val oldItemCount = getItemCount()
previews.addAll(newPreviews)
if (firstImagePos < 0) {
val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image }
if (pos >= 0) firstImagePos = insertPos + pos
}
- if (wasEmpty) {
- // we don't want any item animation in that case
- notifyDataSetChanged()
+ if (insertPos == 0) {
+ if (oldItemCount > 0) {
+ notifyItemRangeRemoved(0, oldItemCount)
+ }
+ notifyItemRangeInserted(insertPos, getItemCount())
} else {
notifyItemRangeInserted(insertPos, newPreviews.size)
when {
@@ -366,7 +410,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
vh.bind(
previews[position],
imageLoader ?: error("ImageLoader is missing"),
+ previewSize,
+ fadeInDurationMs,
isSharedTransitionElement = position == firstImagePos,
+ editButtonRoleDescription,
previewReadyCallback =
if (
position == firstImagePos && transitionStatusElementCallback != null
@@ -374,7 +421,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
this::onTransitionElementReady
} else {
null
- }
+ },
)
}
}
@@ -416,10 +463,15 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
fun bind(
preview: Preview,
imageLoader: CachingImageLoader,
+ previewSize: Size,
+ fadeInDurationMs: Long,
isSharedTransitionElement: Boolean,
- previewReadyCallback: ((String) -> Unit)?
+ editButtonRoleDescription: CharSequence?,
+ previewReadyCallback: ((String) -> Unit)?,
) {
image.setImageDrawable(null)
+ image.alpha = 1f
+ image.clearAnimation()
(image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params ->
params.dimensionRatio = preview.aspectRatioString
}
@@ -449,30 +501,65 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
editActionContainer?.apply {
setOnClickListener { onClick.run() }
visibility = View.VISIBLE
+ if (editButtonRoleDescription != null) {
+ ViewCompat.setAccessibilityDelegate(
+ this,
+ ViewRoleDescriptionAccessibilityDelegate(editButtonRoleDescription),
+ )
+ }
}
}
resetScope().launch {
- loadImage(preview, imageLoader)
- if (preview.type == PreviewType.Image) {
- previewReadyCallback?.let { callback ->
- image.waitForPreDraw()
- callback(TRANSITION_NAME)
- }
+ loadImage(preview, previewSize, imageLoader)
+ if (preview.type == PreviewType.Image && previewReadyCallback != null) {
+ image.waitForPreDraw()
+ previewReadyCallback(TRANSITION_NAME)
+ } else if (image.isAttachedToWindow()) {
+ fadeInPreview(fadeInDurationMs)
}
}
}
- private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) {
+ private suspend fun loadImage(
+ preview: Preview,
+ previewSize: Size,
+ imageLoader: CachingImageLoader,
+ ) {
val bitmap =
runCatching {
// it's expected for all loading/caching optimizations to be implemented by
// the loader
- imageLoader(preview.uri, true)
+ imageLoader(preview.uri, previewSize, true)
}
.getOrNull()
image.setImageBitmap(bitmap)
}
+ private suspend fun fadeInPreview(durationMs: Long) =
+ suspendCancellableCoroutine { continuation ->
+ val animation =
+ AlphaAnimation(0f, 1f).apply {
+ duration = durationMs
+ interpolator = DecelerateInterpolator()
+ setAnimationListener(
+ object : AnimationListener {
+ override fun onAnimationStart(animation: Animation?) = Unit
+
+ override fun onAnimationRepeat(animation: Animation?) = Unit
+
+ override fun onAnimationEnd(animation: Animation?) {
+ continuation.resumeWith(Result.success(Unit))
+ }
+ }
+ )
+ }
+ image.startAnimation(animation)
+ continuation.invokeOnCancellation {
+ image.clearAnimation()
+ image.alpha = 1f
+ }
+ }
+
private fun resetScope(): CoroutineScope =
CoroutineScope(Dispatchers.Main.immediate).also {
scope?.cancel()
@@ -493,7 +580,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
PluralsMessageFormatter.format(
itemView.context.resources,
mapOf(PLURALS_COUNT to count),
- R.string.other_files
+ R.string.other_files,
)
}
@@ -502,6 +589,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
private class LoadingItemViewHolder(view: View) : ViewHolder(view) {
fun bind() = Unit
+
override fun unbind() = Unit
}
@@ -521,10 +609,73 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
}
+ /**
+ * ItemAnimator to handle a special case of addng first image items into the view. The view is
+ * used with wrap_content width spec thus after adding the first views it, generally, changes
+ * its size and position breaking the animation. This class handles that by preserving loading
+ * idicator position in this special case.
+ */
+ private inner class ItemAnimator() : DefaultItemAnimator() {
+ private var animatedVH: ViewHolder? = null
+ private var originalTranslation = 0f
+
+ override fun recordPreLayoutInformation(
+ state: State,
+ viewHolder: RecyclerView.ViewHolder,
+ changeFlags: Int,
+ payloads: MutableList<Any>,
+ ): ItemHolderInfo {
+ return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let {
+ holderInfo ->
+ if (viewHolder is LoadingItemViewHolder && getChildCount() == 1) {
+ LoadingItemHolderInfo(holderInfo, parentLeft = left)
+ } else {
+ holderInfo
+ }
+ }
+ }
+
+ override fun animateDisappearance(
+ viewHolder: RecyclerView.ViewHolder,
+ preLayoutInfo: ItemHolderInfo,
+ postLayoutInfo: ItemHolderInfo?,
+ ): Boolean {
+ if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) {
+ val view = viewHolder.itemView
+ animatedVH = viewHolder
+ originalTranslation = view.getTranslationX()
+ view.setTranslationX(
+ (preLayoutInfo.parentLeft - left + preLayoutInfo.left).toFloat() - view.left
+ )
+ }
+ return super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
+ }
+
+ override fun onRemoveFinished(viewHolder: RecyclerView.ViewHolder) {
+ if (animatedVH === viewHolder) {
+ viewHolder.itemView.setTranslationX(originalTranslation)
+ animatedVH = null
+ }
+ super.onRemoveFinished(viewHolder)
+ }
+
+ private inner class LoadingItemHolderInfo(holderInfo: ItemHolderInfo, val parentLeft: Int) :
+ ItemHolderInfo() {
+ init {
+ left = holderInfo.left
+ top = holderInfo.top
+ right = holderInfo.right
+ bottom = holderInfo.bottom
+ changeFlags = holderInfo.changeFlags
+ }
+ }
+ }
+
@VisibleForTesting
class BatchPreviewLoader(
private val imageLoader: CachingImageLoader,
private val previews: Flow<Preview>,
+ private val previewSize: Size,
val totalItemCount: Int,
private val onUpdate: (List<Preview>) -> Unit,
private val onCompletion: () -> Unit,
@@ -588,10 +739,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
// imagine is one of the first images never loads so we never
// fill the initial viewport and does not show the previews at
// all.
- imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
+ imageLoader(preview.uri, previewSize, isFirstBlock)?.let {
+ bitmap ->
previewSizeUpdater(preview, bitmap.width, bitmap.height)
- }
- ?: 0
+ } ?: 0
}
.getOrDefault(0)
diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
index 11b7c146..64aa9352 100644
--- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt
+++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
@@ -16,24 +16,36 @@
package com.android.intentresolver.widget
+import android.graphics.Rect
import android.util.Log
import android.view.View
import androidx.core.view.OneShotPreDrawListener
-import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.suspendCancellableCoroutine
internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation ->
val isResumed = AtomicBoolean(false)
- val callback = OneShotPreDrawListener.add(
- this,
- Runnable {
- if (isResumed.compareAndSet(false, true)) {
- continuation.resumeWith(Result.success(Unit))
- } else {
- // it's not really expected but in some unknown corner-case let's not crash
- Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception())
+ val callback =
+ OneShotPreDrawListener.add(
+ this,
+ Runnable {
+ if (isResumed.compareAndSet(false, true)) {
+ continuation.resumeWith(Result.success(Unit))
+ } else {
+ // it's not really expected but in some unknown corner-case let's not crash
+ Log.e(
+ "waitForPreDraw",
+ "An attempt to resume a completed coroutine",
+ Exception()
+ )
+ }
}
- }
- )
+ )
continuation.invokeOnCancellation { callback.removeListener() }
}
+
+internal fun View.isFullyVisible(): Boolean {
+ val rect = Rect()
+ val isVisible = getLocalVisibleRect(rect)
+ return isVisible && rect.width() == width && rect.height() == height
+}
diff --git a/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt b/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt
new file mode 100644
index 00000000..8fe7144a
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.widget
+
+import android.view.View
+import androidx.core.view.AccessibilityDelegateCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
+
+class ViewRoleDescriptionAccessibilityDelegate(private val roleDescription: CharSequence) :
+ AccessibilityDelegateCompat() {
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
+ super.onInitializeAccessibilityNodeInfo(host, info)
+ info.roleDescription = roleDescription
+ }
+}
diff --git a/java/tests/Android.bp b/java/tests/Android.bp
deleted file mode 100644
index 90c7fb7a..00000000
--- a/java/tests/Android.bp
+++ /dev/null
@@ -1,44 +0,0 @@
-package {
- // See: http://go/android-license-faq
- default_applicable_licenses: ["packages_modules_IntentResolver_license"],
-}
-
-android_test {
- name: "IntentResolverUnitTests",
-
- // Include all test java files.
- srcs: ["src/**/*.java", "src/**/*.kt"],
-
- libs: [
- "android.test.runner",
- "android.test.base",
- "android.test.mock",
- "framework",
- "framework-res",
- ],
-
- static_libs: [
- "IntentResolver-core",
- "androidx.test.core",
- "androidx.test.rules",
- "androidx.test.ext.junit",
- "androidx.test.ext.truth",
- "androidx.test.espresso.contrib",
- "androidx.test.espresso.core",
- "androidx.test.rules",
- "androidx.lifecycle_lifecycle-common-java8",
- "androidx.lifecycle_lifecycle-extensions",
- "androidx.lifecycle_lifecycle-runtime-ktx",
- "androidx.lifecycle_lifecycle-runtime-testing",
- "kotlinx_coroutines_test",
- "mockito-target-minus-junit4",
- "testables",
- "truth-prebuilt",
- ],
- plugins: ["dagger2-compiler"],
- test_suites: ["general-tests"],
- sdk_version: "core_platform",
- compile_multilib: "both",
-
- dont_merge_manifests: true,
-}
diff --git a/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt b/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt
deleted file mode 100644
index a17a560c..00000000
--- a/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt
+++ /dev/null
@@ -1,79 +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.intentresolver
-
-import android.os.UserHandle
-
-import com.google.common.truth.Truth.assertThat
-
-import org.junit.Test
-
-class AnnotatedUserHandlesTest {
-
- @Test
- fun testBasicProperties() { // Fields that are reflected back w/o logic.
- val info = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(42)
- .setUserHandleSharesheetLaunchedAs(UserHandle.of(116))
- .setPersonalProfileUserHandle(UserHandle.of(117))
- .setWorkProfileUserHandle(UserHandle.of(118))
- .setCloneProfileUserHandle(UserHandle.of(119))
- .build()
-
- assertThat(info.userIdOfCallingApp).isEqualTo(42)
- assertThat(info.userHandleSharesheetLaunchedAs.identifier).isEqualTo(116)
- assertThat(info.personalProfileUserHandle.identifier).isEqualTo(117)
- assertThat(info.workProfileUserHandle.identifier).isEqualTo(118)
- assertThat(info.cloneProfileUserHandle.identifier).isEqualTo(119)
- }
-
- @Test
- fun testWorkTabInitiallySelectedWhenLaunchedFromWorkProfile() {
- val info = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(42)
- .setPersonalProfileUserHandle(UserHandle.of(101))
- .setWorkProfileUserHandle(UserHandle.of(202))
- .setUserHandleSharesheetLaunchedAs(UserHandle.of(202))
- .build()
-
- assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(202)
- }
-
- @Test
- fun testPersonalTabInitiallySelectedWhenLaunchedFromPersonalProfile() {
- val info = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(42)
- .setPersonalProfileUserHandle(UserHandle.of(101))
- .setWorkProfileUserHandle(UserHandle.of(202))
- .setUserHandleSharesheetLaunchedAs(UserHandle.of(101))
- .build()
-
- assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101)
- }
-
- @Test
- fun testPersonalTabInitiallySelectedWhenLaunchedFromOtherProfile() {
- val info = AnnotatedUserHandles.newBuilder()
- .setUserIdOfCallingApp(42)
- .setPersonalProfileUserHandle(UserHandle.of(101))
- .setWorkProfileUserHandle(UserHandle.of(202))
- .setUserHandleSharesheetLaunchedAs(UserHandle.of(303))
- .build()
-
- assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101)
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt
deleted file mode 100644
index 9a5dabdb..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt
+++ /dev/null
@@ -1,71 +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.intentresolver
-
-import android.content.ComponentName
-import android.provider.Settings
-import android.testing.TestableContext
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ChooserIntegratedDeviceComponentsTest {
- private val secureSettings = mock<SecureSettings>()
- private val testableContext =
- TestableContext(InstrumentationRegistry.getInstrumentation().getContext())
-
- @Test
- fun testEditorAndNearby() {
- val resources = testableContext.getOrCreateTestableResources()
-
- resources.addOverride(R.string.config_systemImageEditor, "")
- resources.addOverride(R.string.config_defaultNearbySharingComponent, "")
-
- var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
-
- assertThat(components.editSharingComponent).isNull()
- assertThat(components.nearbySharingComponent).isNull()
-
- val editor = ComponentName.unflattenFromString("com.android/com.android.Editor")
- val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby")
-
- resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString())
- resources.addOverride(
- R.string.config_defaultNearbySharingComponent, nearby?.flattenToString())
-
- components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
-
- assertThat(components.editSharingComponent).isEqualTo(editor)
- assertThat(components.nearbySharingComponent).isEqualTo(nearby)
-
- val anotherNearby =
- ComponentName.unflattenFromString("com.android/com.android.another_nearby")
- whenever(
- secureSettings.getString(
- any(),
- eq(Settings.Secure.NEARBY_SHARING_COMPONENT)
- )
- ).thenReturn(anotherNearby?.flattenToString())
-
- components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
-
- assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby)
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt
deleted file mode 100644
index 331d1c21..00000000
--- a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt
+++ /dev/null
@@ -1,88 +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.intentresolver
-
-import android.app.PendingIntent
-import android.content.Intent
-import android.graphics.drawable.Icon
-import android.net.Uri
-import android.service.chooser.ChooserAction
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ChooserRequestParametersTest {
- val flags = TestFeatureFlagRepository(mapOf())
-
- @Test
- fun testChooserActions() {
- val actionCount = 3
- val intent = Intent(Intent.ACTION_SEND)
- val actions = createChooserActions(actionCount)
- val chooserIntent =
- Intent(Intent.ACTION_CHOOSER).apply {
- putExtra(Intent.EXTRA_INTENT, intent)
- putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions)
- }
- val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags)
- assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder()
- }
-
- @Test
- fun testChooserActions_empty() {
- val intent = Intent(Intent.ACTION_SEND)
- val chooserIntent =
- Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) }
- val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags)
- assertThat(request.chooserActions).isEmpty()
- }
-
- @Test
- fun testChooserActions_tooMany() {
- val intent = Intent(Intent.ACTION_SEND)
- val chooserActions = createChooserActions(10)
- val chooserIntent =
- Intent(Intent.ACTION_CHOOSER).apply {
- putExtra(Intent.EXTRA_INTENT, intent)
- putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions)
- }
-
- val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags)
-
- val expectedActions = chooserActions.sliceArray(0 until 5)
- assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder()
- }
-
- private fun createChooserActions(count: Int): Array<ChooserAction> {
- return Array(count) { i -> createChooserAction("$i") }
- }
-
- private fun createChooserAction(label: CharSequence): ChooserAction {
- val icon = Icon.createWithContentUri("content://org.package.app/image")
- val pendingIntent =
- PendingIntent.getBroadcast(
- InstrumentationRegistry.getInstrumentation().getTargetContext(),
- 0,
- Intent("TESTACTION"),
- PendingIntent.FLAG_IMMUTABLE
- )
- return ChooserAction.Builder(icon, label, pendingIntent).build()
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt
deleted file mode 100644
index 3fa01bcc..00000000
--- a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt
+++ /dev/null
@@ -1,56 +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.intentresolver
-
-import com.android.systemui.flags.BooleanFlag
-import org.junit.rules.TestRule
-import org.junit.runner.Description
-import org.junit.runners.model.Statement
-
-/**
- * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not
- * meet in the active flag set.
- * @param flags active flag set
- */
-internal class FeatureFlagRule(flags: Map<BooleanFlag, Boolean>) : TestRule {
- private val flags = flags.entries.fold(HashMap<String, Boolean>()) { map, (key, value) ->
- map.apply {
- put(key.name, value)
- }
- }
- private val skippingStatement = object : Statement() {
- override fun evaluate() = Unit
- }
-
- override fun apply(base: Statement, description: Description): Statement {
- val annotation = description.annotations.firstOrNull {
- it is RequireFeatureFlags
- } as? RequireFeatureFlags
- ?: return base
-
- if (annotation.flags.size != annotation.values.size) {
- error("${description.className}#${description.methodName}: inconsistent number of" +
- " flags and values in $annotation")
- }
- for (i in annotation.flags.indices) {
- val flag = annotation.flags[i]
- val value = annotation.values[i]
- if (flags.getOrDefault(flag, !value) != value) return skippingStatement
- }
- return base
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
deleted file mode 100644
index aaa7a282..00000000
--- a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver
-
-/**
- * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects
- * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not
- * be null"). To fix this, we can use methods that modify the return type to be nullable. This
- * causes Kotlin to skip the null checks.
- * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt
- */
-
-import org.mockito.ArgumentCaptor
-import org.mockito.ArgumentMatcher
-import org.mockito.ArgumentMatchers
-import org.mockito.Mockito
-import org.mockito.stubbing.OngoingStubbing
-
-/**
- * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-fun <T> eq(obj: T): T = Mockito.eq<T>(obj)
-
-/**
- * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
-inline fun <reified T> any(): T = any(T::class.java)
-
-/**
- * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when
- * null is returned.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher)
-
-/**
- * Kotlin type-inferred version of Mockito.nullable()
- */
-inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java)
-
-/**
- * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException
- * when null is returned.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
-
-/**
- * Helper function for creating an argumentCaptor in kotlin.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
- ArgumentCaptor.forClass(T::class.java)
-
-/**
- * Helper function for creating new mocks, without the need to pass in a [Class] instance.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- *
- * @param apply builder function to simplify stub configuration by improving type inference.
- */
-inline fun <reified T : Any> mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java)
- .apply(apply)
-
-/**
- * Helper function for stubbing methods without the need to use backticks.
- *
- * @see Mockito.when
- */
-fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall)
-
-/**
- * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when
- * kotlin tests are mocking kotlin objects and the methods take non-null parameters:
- *
- * java.lang.NullPointerException: capture() must not be null
- */
-class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) {
- private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz)
- fun capture(): T = wrapped.capture()
- val value: T
- get() = wrapped.value
- val allValues: List<T>
- get() = wrapped.allValues
-}
-
-/**
- * Helper function for creating an argumentCaptor in kotlin.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
- KotlinArgumentCaptor(T::class.java)
-
-/**
- * Helper function for creating and using a single-use ArgumentCaptor in kotlin.
- *
- * val captor = argumentCaptor<Foo>()
- * verify(...).someMethod(captor.capture())
- * val captured = captor.value
- *
- * becomes:
- *
- * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
- *
- * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
- */
-inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T =
- kotlinArgumentCaptor<T>().apply { block() }.value
-
-/**
- * Variant of [withArgCaptor] for capturing multiple arguments.
- *
- * val captor = argumentCaptor<Foo>()
- * verify(...).someMethod(captor.capture())
- * val captured: List<Foo> = captor.allValues
- *
- * becomes:
- *
- * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
- */
-inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
- kotlinArgumentCaptor<T>().apply{ block() }.allValues
-
-inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true })
diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
deleted file mode 100644
index 9ddeed84..00000000
--- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
+++ /dev/null
@@ -1,313 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver
-
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ResolveInfo
-import android.content.pm.ShortcutInfo
-import android.os.UserHandle
-import android.service.chooser.ChooserTarget
-import com.android.intentresolver.chooser.DisplayResolveInfo
-import com.android.intentresolver.chooser.TargetInfo
-import androidx.test.filters.SmallTest
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-private const val PACKAGE_A = "package.a"
-private const val PACKAGE_B = "package.b"
-private const val CLASS_NAME = "./MainActivity"
-
-@SmallTest
-class ShortcutSelectionLogicTest {
- private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser()
-
- private val packageTargets = HashMap<String, Array<ChooserTarget>>().apply {
- arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg ->
- // shortcuts in reverse priority order
- val targets = Array(3) { i ->
- createChooserTarget(
- "Shortcut $i",
- (i + 1).toFloat() / 10f,
- ComponentName(pkg, CLASS_NAME),
- pkg.shortcutId(i),
- )
- }
- this[pkg] = targets
- }
- }
-
- private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
- Intent(),
- ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
- "label",
- "extended info",
- Intent(),
- /* resolveInfoPresentationGetter= */ null)
-
- private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
- Intent(),
- ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE),
- "label 2",
- "extended info 2",
- Intent(),
- /* resolveInfoPresentationGetter= */ null)
-
- private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) =
- this[pkg]?.get(idx) ?: error("missing package $pkg")
-
- @Test
- fun testAddShortcuts_no_limits() {
- val serviceResults = ArrayList<TargetInfo>()
- val sc1 = packageTargets[PACKAGE_A, 0]
- val sc2 = packageTargets[PACKAGE_A, 1]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ false
- )
-
- val isUpdated = testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0.1f,
- /* targets = */ listOf(sc1, sc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
-
- assertTrue("Updates are expected", isUpdated)
- assertShortcutsInOrder(
- listOf(sc2, sc1),
- serviceResults,
- "Two shortcuts are expected as we do not apply per-app shortcut limit"
- )
- }
-
- @Test
- fun testAddShortcuts_same_package_with_per_package_limit() {
- val serviceResults = ArrayList<TargetInfo>()
- val sc1 = packageTargets[PACKAGE_A, 0]
- val sc2 = packageTargets[PACKAGE_A, 1]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ true
- )
-
- val isUpdated = testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0.1f,
- /* targets = */ listOf(sc1, sc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
-
- assertTrue("Updates are expected", isUpdated)
- assertShortcutsInOrder(
- listOf(sc2),
- serviceResults,
- "One shortcut is expected as we apply per-app shortcut limit"
- )
- }
-
- @Test
- fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() {
- val serviceResults = ArrayList<TargetInfo>()
- val sc1 = packageTargets[PACKAGE_A, 0]
- val sc2 = packageTargets[PACKAGE_A, 1]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ false
- )
-
- val isUpdated = testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0.1f,
- /* targets = */ listOf(sc1, sc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 1,
- /* serviceTargets = */ serviceResults
- )
-
- assertTrue("Updates are expected", isUpdated)
- assertShortcutsInOrder(
- listOf(sc2),
- serviceResults,
- "One shortcut is expected as we apply overall shortcut limit"
- )
- }
-
- @Test
- fun testAddShortcuts_different_packages_with_per_package_limit() {
- val serviceResults = ArrayList<TargetInfo>()
- val pkgAsc1 = packageTargets[PACKAGE_A, 0]
- val pkgAsc2 = packageTargets[PACKAGE_A, 1]
- val pkgBsc1 = packageTargets[PACKAGE_B, 0]
- val pkgBsc2 = packageTargets[PACKAGE_B, 1]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ true
- )
-
- testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0.1f,
- /* targets = */ listOf(pkgAsc1, pkgAsc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
- testSubject.addServiceResults(
- /* origTarget = */ otherBaseDisplayInfo,
- /* origTargetScore = */ 0.2f,
- /* targets = */ listOf(pkgBsc1, pkgBsc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
-
- assertShortcutsInOrder(
- listOf(pkgBsc2, pkgAsc2),
- serviceResults,
- "Two shortcuts are expected as we apply per-app shortcut limit"
- )
- }
-
- @Test
- fun testAddShortcuts_pinned_shortcut() {
- val serviceResults = ArrayList<TargetInfo>()
- val sc1 = packageTargets[PACKAGE_A, 0]
- val sc2 = packageTargets[PACKAGE_A, 1]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ false
- )
-
- val isUpdated = testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0.1f,
- /* targets = */ listOf(sc1, sc2),
- /* isShortcutResult = */ true,
- /* directShareToShortcutInfos = */ mapOf(
- sc1 to createShortcutInfo(
- PACKAGE_A.shortcutId(1),
- sc1.componentName, 1).apply {
- addFlags(ShortcutInfo.FLAG_PINNED)
- }
- ),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ mock(),
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
-
- assertTrue("Updates are expected", isUpdated)
- assertShortcutsInOrder(
- listOf(sc1, sc2),
- serviceResults,
- "Two shortcuts are expected as we do not apply per-app shortcut limit"
- )
- }
-
- @Test
- fun test_available_caller_shortcuts_count_is_limited() {
- val serviceResults = ArrayList<TargetInfo>()
- val sc1 = packageTargets[PACKAGE_A, 0]
- val sc2 = packageTargets[PACKAGE_A, 1]
- val sc3 = packageTargets[PACKAGE_A, 2]
- val testSubject = ShortcutSelectionLogic(
- /* maxShortcutTargetsPerApp = */ 1,
- /* applySharingAppLimits = */ true
- )
- val context = mock<Context> {
- whenever(packageManager).thenReturn(mock())
- }
-
- testSubject.addServiceResults(
- /* origTarget = */ baseDisplayInfo,
- /* origTargetScore = */ 0f,
- /* targets = */ listOf(sc1, sc2, sc3),
- /* isShortcutResult = */ false,
- /* directShareToShortcutInfos = */ emptyMap(),
- /* directShareToAppTargets = */ emptyMap(),
- /* userContext = */ context,
- /* targetIntent = */ mock(),
- /* refererFillInIntent = */ mock(),
- /* maxRankedTargets = */ 4,
- /* serviceTargets = */ serviceResults
- )
-
- assertShortcutsInOrder(
- listOf(sc3, sc2),
- serviceResults,
- "At most two caller-provided shortcuts are allowed"
- )
- }
-
- // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases
- // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`.
- private fun assertShortcutsInOrder(
- expected: List<ChooserTarget>, actual: List<TargetInfo>, msg: String? = ""
- ) {
- assertEquals(msg, expected.size, actual.size)
- for (i in expected.indices) {
- assertEquals(
- "Unexpected item at position $i",
- expected[i].componentName,
- actual[i].chooserTargetComponentName
- )
- assertEquals(
- "Unexpected item at position $i",
- expected[i].title,
- actual[i].displayLabel
- )
- }
- }
-
- private fun String.shortcutId(id: Int) = "$this.$id"
-}
diff --git a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt
deleted file mode 100644
index e62672a3..00000000
--- a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver
-
-import com.android.intentresolver.ResolverDataProvider
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-
-/**
- * Unit tests for the various implementations of {@link TargetPresentationGetter}.
- * TODO: consider expanding to cover icon logic (not just labels/sublabels).
- * TODO: these are conceptually "acceptance tests" that provide comprehensive coverage of the
- * apparent variations in the legacy implementation. The tests probably don't have to be so
- * exhaustive if we're able to impose a simpler design on the implementation.
- */
-class TargetPresentationGetterTest {
- fun makeResolveInfoPresentationGetter(
- withSubstitutePermission: Boolean,
- appLabel: String,
- activityLabel: String,
- resolveInfoLabel: String): TargetPresentationGetter {
- val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo(
- withSubstitutePermission, appLabel, activityLabel, resolveInfoLabel)
- val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100)
- return factory.makePresentationGetter(testPackageInfo.resolveInfo)
- }
-
- fun makeActivityInfoPresentationGetter(
- withSubstitutePermission: Boolean,
- appLabel: String?,
- activityLabel: String?): TargetPresentationGetter {
- val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo(
- withSubstitutePermission, appLabel, activityLabel, "")
- val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100)
- return factory.makePresentationGetter(testPackageInfo.activityInfo)
- }
-
- @Test
- fun testActivityInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(
- false, "app_label", "activity_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
- }
-
- @Test
- fun testActivityInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(
- false, "app_label", "app_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // Without the substitute permission, there's no logic to dedupe the labels.
- // TODO: this matches our observations in the legacy code, but is it the right behavior? It
- // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
- // the UI at least, but maybe that logic should be pulled back to the "presentation"?
- assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
- }
-
- @Test
- fun testActivityInfoLabels_noSubstitutePermission_nullRequestedLabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(false, null, "activity_label")
- assertThat(presentationGetter.getLabel()).isNull()
- assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
- }
-
- @Test
- fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedLabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(false, "", "activity_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("")
- assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
- }
-
- @Test
- fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // Without the substitute permission, empty sublabels are passed through as-is.
- assertThat(presentationGetter.getSubLabel()).isEqualTo("")
- }
-
- @Test
- fun testActivityInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(
- true, "app_label", "activity_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
- // With the substitute permission, the same ("activity") label is requested as both the label
- // and sublabel, even though the other value ("app_label") was distinct. Thus this behaves the
- // same as a dupe.
- assertThat(presentationGetter.getSubLabel()).isEqualTo(null)
- }
-
- @Test
- fun testActivityInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(
- true, "app_label", "app_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // With the substitute permission, duped sublabels get converted to nulls.
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testActivityInfoLabels_withSubstitutePermission_nullRequestedLabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", null)
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // With the substitute permission, null inputs are a special case that produces null outputs
- // (i.e., they're not simply passed-through from the inputs).
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedLabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "")
- // Empty "labels" are taken as-is and (unlike nulls) don't prompt a fallback to the sublabel.
- // Thus (as in the previous case with substitute permission & "distinct" labels), this is
- // treated as a dupe.
- assertThat(presentationGetter.getLabel()).isEqualTo("")
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
- val presentationGetter = makeActivityInfoPresentationGetter(true, "", "activity_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
- // With the substitute permission, empty sublabels get converted to nulls.
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testResolveInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- false, "app_label", "activity_label", "resolve_info_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
- }
-
- @Test
- fun testResolveInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- false, "app_label", "activity_label", "app_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // Without the substitute permission, there's no logic to dedupe the labels.
- // TODO: this matches our observations in the legacy code, but is it the right behavior? It
- // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
- // the UI at least, but maybe that logic should be pulled back to the "presentation"?
- assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
- }
-
- @Test
- fun testResolveInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- false, "app_label", "activity_label", "")
- assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
- // Without the substitute permission, empty sublabels are passed through as-is.
- assertThat(presentationGetter.getSubLabel()).isEqualTo("")
- }
-
- @Test
- fun testResolveInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- true, "app_label", "activity_label", "resolve_info_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
- assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
- }
-
- @Test
- fun testResolveInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- true, "app_label", "activity_label", "activity_label")
- assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
- // With the substitute permission, duped sublabels get converted to nulls.
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- true, "app_label", "activity_label", "")
- assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
- // With the substitute permission, empty sublabels get converted to nulls.
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-
- @Test
- fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedLabelAndSublabel() {
- val presentationGetter = makeResolveInfoPresentationGetter(
- true, "app_label", "", "")
- assertThat(presentationGetter.getLabel()).isEqualTo("")
- // With the substitute permission, empty sublabels get converted to nulls.
- assertThat(presentationGetter.getSubLabel()).isNull()
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt
deleted file mode 100644
index d239f612..00000000
--- a/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt
+++ /dev/null
@@ -1,56 +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.intentresolver
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewmodel.CreationExtras
-import com.android.intentresolver.contentpreview.BasePreviewViewModel
-import com.android.intentresolver.contentpreview.ImageLoader
-import com.android.intentresolver.contentpreview.PreviewDataProvider
-
-/** A test content preview model that supports image loader override. */
-class TestContentPreviewViewModel(
- private val viewModel: BasePreviewViewModel,
- private val imageLoader: ImageLoader? = null,
-) : BasePreviewViewModel() {
- override fun createOrReuseProvider(
- chooserRequest: ChooserRequestParameters
- ): PreviewDataProvider = viewModel.createOrReuseProvider(chooserRequest)
-
- override fun createOrReuseImageLoader(): ImageLoader =
- imageLoader ?: viewModel.createOrReuseImageLoader()
-
- companion object {
- fun wrap(
- factory: ViewModelProvider.Factory,
- imageLoader: ImageLoader?,
- ): ViewModelProvider.Factory =
- object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun <T : ViewModel> create(
- modelClass: Class<T>,
- extras: CreationExtras
- ): T {
- return TestContentPreviewViewModel(
- factory.create(modelClass, extras) as BasePreviewViewModel,
- imageLoader,
- ) as T
- }
- }
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
deleted file mode 100644
index f9d3dd96..00000000
--- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
+++ /dev/null
@@ -1,399 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver.chooser
-
-import android.app.prediction.AppTarget
-import android.app.prediction.AppTargetId
-import android.content.ComponentName
-import android.content.Intent
-import android.content.pm.ActivityInfo
-import android.content.pm.ResolveInfo
-import android.graphics.drawable.AnimatedVectorDrawable
-import android.os.UserHandle
-import android.test.UiThreadTest
-import androidx.test.platform.app.InstrumentationRegistry
-import com.android.intentresolver.ResolverDataProvider
-import com.android.intentresolver.createChooserTarget
-import com.android.intentresolver.createShortcutInfo
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.google.common.truth.Truth.assertThat
-import org.junit.Before
-import org.junit.Test
-import org.mockito.Mockito.any
-import org.mockito.Mockito.never
-import org.mockito.Mockito.spy
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-class TargetInfoTest {
- private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser()
-
- private val context = InstrumentationRegistry.getInstrumentation().getContext()
-
- @Before
- fun setup() {
- // SelectableTargetInfo reads DeviceConfig and needs a permission for that.
- InstrumentationRegistry
- .getInstrumentation()
- .getUiAutomation()
- .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG")
- }
-
- @Test
- fun testNewEmptyTargetInfo() {
- val info = NotSelectableTargetInfo.newEmptyTargetInfo()
- assertThat(info.isEmptyTargetInfo()).isTrue()
- assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model.
- assertThat(info.hasDisplayIcon()).isFalse()
- assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull()
- }
-
- @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper.
- @Test
- fun testNewPlaceholderTargetInfo() {
- val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context)
- assertThat(info.isPlaceHolderTargetInfo).isTrue()
- assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model.
- assertThat(info.hasDisplayIcon()).isTrue()
- assertThat(info.displayIconHolder.displayIcon)
- .isInstanceOf(AnimatedVectorDrawable::class.java)
- // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing
- // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is
- // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get
- // it working myself.
- }
-
- @Test
- fun testNewSelectableTargetInfo() {
- val resolvedIntent = Intent()
- val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
- resolvedIntent,
- ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE),
- "label",
- "extended info",
- resolvedIntent,
- /* resolveInfoPresentationGetter= */ null)
- val chooserTarget = createChooserTarget(
- "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id")
- val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3)
- val appTarget = AppTarget(
- AppTargetId("id"),
- chooserTarget.componentName.packageName,
- chooserTarget.componentName.className,
- UserHandle.CURRENT)
-
- val targetInfo = SelectableTargetInfo.newSelectableTargetInfo(
- baseDisplayInfo,
- mock(),
- resolvedIntent,
- chooserTarget,
- 0.1f,
- shortcutInfo,
- appTarget,
- mock(),
- )
- assertThat(targetInfo.isSelectableTargetInfo).isTrue()
- assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model.
- assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo)
- assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName)
- assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id)
- assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo)
- assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget)
- assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent)
- // TODO: make more meaningful assertions about the behavior of a selectable target.
- }
-
- @Test
- fun test_SelectableTargetInfo_componentName_no_source_info() {
- val chooserTarget = createChooserTarget(
- "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id")
- val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3)
- val appTarget = AppTarget(
- AppTargetId("id"),
- chooserTarget.componentName.packageName,
- chooserTarget.componentName.className,
- UserHandle.CURRENT)
- val pkgName = "org.package"
- val className = "MainActivity"
- val backupResolveInfo = ResolveInfo().apply {
- activityInfo = ActivityInfo().apply {
- packageName = pkgName
- name = className
- }
- }
-
- val targetInfo = SelectableTargetInfo.newSelectableTargetInfo(
- null,
- backupResolveInfo,
- mock(),
- chooserTarget,
- 0.1f,
- shortcutInfo,
- appTarget,
- mock(),
- )
- assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className))
- }
-
- @Test
- fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() {
- val resolvedIntent = Intent("DONT_REFINE_ME")
- resolvedIntent.putExtra("resolvedIntent", true)
-
- val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
- resolvedIntent,
- ResolverDataProvider.createResolveInfo(1, 0),
- "label",
- "extended info",
- resolvedIntent,
- /* resolveInfoPresentationGetter= */ null)
- val chooserTarget = createChooserTarget(
- "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id")
- val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3)
- val appTarget = AppTarget(
- AppTargetId("id"),
- chooserTarget.componentName.packageName,
- chooserTarget.componentName.className,
- UserHandle.CURRENT)
-
- val targetInfo = SelectableTargetInfo.newSelectableTargetInfo(
- baseDisplayInfo,
- mock(),
- resolvedIntent,
- chooserTarget,
- 0.1f,
- shortcutInfo,
- appTarget,
- mock(),
- )
-
- val refinement = Intent("PROPOSED_REFINEMENT")
- assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
- }
-
- @Test
- fun testNewDisplayResolveInfo() {
- val intent = Intent(Intent.ACTION_SEND)
- intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
- intent.setType("text/plain")
-
- val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE)
-
- val targetInfo = DisplayResolveInfo.newDisplayResolveInfo(
- intent,
- resolveInfo,
- "label",
- "extended info",
- intent,
- /* resolveInfoPresentationGetter= */ null)
- assertThat(targetInfo.isDisplayResolveInfo()).isTrue()
- assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse()
- assertThat(targetInfo.isChooserTargetInfo()).isFalse()
- }
-
- @Test
- fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() {
- val originalIntent = Intent("DONT_REFINE_ME")
- originalIntent.putExtra("originalIntent", true)
- val mismatchedAlternate = Intent("DOESNT_MATCH")
- mismatchedAlternate.putExtra("mismatchedAlternate", true)
- val targetAlternate = Intent("REFINE_ME")
- targetAlternate.putExtra("targetAlternate", true)
- val extraMatch = Intent("REFINE_ME")
- extraMatch.putExtra("extraMatch", true)
-
- val originalInfo = DisplayResolveInfo.newDisplayResolveInfo(
- originalIntent,
- ResolverDataProvider.createResolveInfo(3, 0),
- "label",
- "extended info",
- originalIntent,
- /* resolveInfoPresentationGetter= */ null)
- originalInfo.addAlternateSourceIntent(mismatchedAlternate)
- originalInfo.addAlternateSourceIntent(targetAlternate)
- originalInfo.addAlternateSourceIntent(extraMatch)
-
- val refinement = Intent("REFINE_ME") // First match is `targetAlternate`
- refinement.putExtra("refinement", true)
-
- val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
- // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`.
- assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue()
- assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("targetAlternate", false))
- .isTrue()
- // None of the other source intents got merged in (not even the later one that matched):
- assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("originalIntent", false))
- .isFalse()
- assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false))
- .isFalse()
- assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse()
- }
-
- @Test
- fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() {
- val originalIntent = Intent("DONT_REFINE_ME")
- originalIntent.putExtra("originalIntent", true)
- val mismatchedAlternate = Intent("DOESNT_MATCH")
- mismatchedAlternate.putExtra("mismatchedAlternate", true)
-
- val originalInfo = DisplayResolveInfo.newDisplayResolveInfo(
- originalIntent,
- ResolverDataProvider.createResolveInfo(3, 0),
- "label",
- "extended info",
- originalIntent,
- /* resolveInfoPresentationGetter= */ null)
- originalInfo.addAlternateSourceIntent(mismatchedAlternate)
-
- val refinement = Intent("PROPOSED_REFINEMENT")
- assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
- }
-
- @Test
- fun testNewMultiDisplayResolveInfo() {
- val intent = Intent(Intent.ACTION_SEND)
- intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
- intent.setType("text/plain")
-
- val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE)
- val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo(
- intent,
- resolveInfo,
- "label 1",
- "extended info 1",
- intent,
- /* resolveInfoPresentationGetter= */ null)
- val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo(
- intent,
- resolveInfo,
- "label 2",
- "extended info 2",
- intent,
- /* resolveInfoPresentationGetter= */ null)
-
- val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
- listOf(firstTargetInfo, secondTargetInfo))
-
- assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue()
- assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance.
- assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse()
-
- assertThat(multiTargetInfo.getExtendedInfo()).isNull()
-
- assertThat(multiTargetInfo.getAllDisplayTargets())
- .containsExactly(firstTargetInfo, secondTargetInfo)
-
- assertThat(multiTargetInfo.hasSelected()).isFalse()
- assertThat(multiTargetInfo.getSelectedTarget()).isNull()
-
- multiTargetInfo.setSelected(1)
-
- assertThat(multiTargetInfo.hasSelected()).isTrue()
- assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo)
-
- val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent)
- assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java)
- assertThat((refined as MultiDisplayResolveInfo).hasSelected())
- .isEqualTo(multiTargetInfo.hasSelected())
-
- // TODO: consider exercising activity-start behavior.
- // TODO: consider exercising DisplayResolveInfo base class behavior.
- }
-
- @Test
- fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() {
- val sendImage = Intent("SEND").apply { type = "image/png" }
- val sendUri = Intent("SEND").apply { type = "text/uri" }
-
- val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0)
-
- val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo(
- sendImage,
- resolveInfo,
- "Send Image",
- "Sends only images",
- sendImage,
- /* resolveInfoPresentationGetter= */ null)
-
- val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo(
- sendUri,
- resolveInfo,
- "Send Text",
- "Sends only text",
- sendUri,
- /* resolveInfoPresentationGetter= */ null)
-
- val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo(
- sendImage,
- resolveInfo,
- "Send Image or Text",
- "Sends images or text",
- sendImage,
- /* resolveInfoPresentationGetter= */ null
- ).apply {
- addAlternateSourceIntent(sendUri)
- }
-
- val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
- listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget)
- )
-
- multiTargetInfo.setSelected(0)
- assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget)
- assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents)
-
- multiTargetInfo.setSelected(1)
- assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget)
- assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents)
-
- multiTargetInfo.setSelected(2)
- assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget)
- assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents)
- }
-
- @Test
- fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() {
- val refined = Intent("SEND")
- val sendImage = Intent("SEND")
- val targetOne = spy(
- DisplayResolveInfo.newDisplayResolveInfo(
- sendImage,
- ResolverDataProvider.createResolveInfo(1, 0),
- "Target One",
- "Target One",
- sendImage,
- /* resolveInfoPresentationGetter= */ null
- )
- )
- val targetTwo = mock<DisplayResolveInfo> {
- whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this)
- }
-
- val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
- listOf(targetOne, targetTwo)
- )
-
- multiTargetInfo.setSelected(1)
- assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo)
-
- multiTargetInfo.tryToCloneWithAppliedRefinement(refined)
- verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined)
- verify(targetOne, never()).tryToCloneWithAppliedRefinement(any())
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt
deleted file mode 100644
index fe13a215..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt
+++ /dev/null
@@ -1,225 +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.intentresolver.contentpreview
-
-import android.net.Uri
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.lifecycle.testing.TestLifecycleOwner
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
-import com.android.intentresolver.R
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.android.intentresolver.widget.ActionRow
-import com.google.common.truth.Truth.assertThat
-import java.util.function.Consumer
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-private const val HEADLINE_IMAGES = "Image Headline"
-private const val HEADLINE_VIDEOS = "Video Headline"
-private const val HEADLINE_FILES = "Files Headline"
-private const val SHARED_TEXT = "Some text to share"
-
-@RunWith(AndroidJUnit4::class)
-class FilesPlusTextContentPreviewUiTest {
- private val lifecycleOwner = TestLifecycleOwner()
- private val actionFactory =
- object : ChooserContentPreviewUi.ActionFactory {
- override fun getEditButtonRunnable(): Runnable? = null
- override fun getCopyButtonRunnable(): Runnable? = null
- override fun createCustomActions(): List<ActionRow.Action> = emptyList()
- override fun getModifyShareAction(): ActionRow.Action? = null
- override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
- }
- private val imageLoader = mock<ImageLoader>()
- private val headlineGenerator =
- mock<HeadlineGenerator> {
- whenever(getImagesHeadline(anyInt())).thenReturn(HEADLINE_IMAGES)
- whenever(getVideosHeadline(anyInt())).thenReturn(HEADLINE_VIDEOS)
- whenever(getFilesHeadline(anyInt())).thenReturn(HEADLINE_FILES)
- }
-
- private val context
- get() = getInstrumentation().getContext()
-
- @Test
- fun test_displayImagesPlusTextWithoutUriMetadata_showImagesHeadline() {
- val sharedFileCount = 2
- val previewView = testLoadingHeadline("image/*", sharedFileCount)
-
- verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_IMAGES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() {
- val sharedFileCount = 2
- val previewView = testLoadingHeadline("video/*", sharedFileCount)
-
- verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_VIDEOS)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() {
- val sharedFileCount = 2
- val previewView = testLoadingHeadline("application/pdf", sharedFileCount)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_FILES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() {
- val sharedFileCount = 2
- val previewView = testLoadingHeadline("*/*", sharedFileCount)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_FILES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() {
- val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg")
- val sharedFileCount = loadedFileMetadata.size
- val previewView = testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata)
-
- verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_IMAGES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() {
- val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4")
- val sharedFileCount = loadedFileMetadata.size
- val previewView = testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata)
-
- verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_VIDEOS)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() {
- val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4")
- val sharedFileCount = loadedFileMetadata.size
- val previewView = testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_FILES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() {
- val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf")
- val sharedFileCount = loadedFileMetadata.size
- val previewView =
- testLoadingHeadline("application/pdf", sharedFileCount, loadedFileMetadata)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_FILES)
- verifySharedText(previewView)
- }
-
- @Test
- fun test_uriMetadataIsMoreSpecificThanIntentMimeType_headlineGetsUpdated() {
- val sharedFileCount = 2
- val testSubject =
- FilesPlusTextContentPreviewUi(
- lifecycleOwner.lifecycle,
- /*isSingleImage=*/ false,
- sharedFileCount,
- SHARED_TEXT,
- /*intentMimeType=*/ "*/*",
- actionFactory,
- imageLoader,
- DefaultMimeTypeClassifier,
- headlineGenerator
- )
- val layoutInflater = LayoutInflater.from(context)
- val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup
-
- val previewView =
- testSubject.display(context.resources, LayoutInflater.from(context), gridLayout)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_FILES)
-
- testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg"))
-
- verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
- verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
- verifyPreviewHeadline(previewView, HEADLINE_IMAGES)
- }
-
- private fun testLoadingHeadline(
- intentMimeType: String,
- sharedFileCount: Int,
- loadedFileMetadata: List<FileInfo>? = null
- ): ViewGroup? {
- val testSubject =
- FilesPlusTextContentPreviewUi(
- lifecycleOwner.lifecycle,
- /*isSingleImage=*/ false,
- sharedFileCount,
- SHARED_TEXT,
- intentMimeType,
- actionFactory,
- imageLoader,
- DefaultMimeTypeClassifier,
- headlineGenerator
- )
- val layoutInflater = LayoutInflater.from(context)
- val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup
-
- loadedFileMetadata?.let(testSubject::updatePreviewMetadata)
- return testSubject.display(context.resources, LayoutInflater.from(context), gridLayout)
- }
-
- private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List<FileInfo> {
- val uri = Uri.parse("content://pkg.app/file")
- return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() }
- }
-
- private fun verifyPreviewHeadline(previewView: ViewGroup?, expectedText: String) {
- assertThat(previewView).isNotNull()
- val headlineView = previewView?.findViewById<TextView>(R.id.headline)
- assertThat(headlineView).isNotNull()
- assertThat(headlineView?.text).isEqualTo(expectedText)
- }
-
- private fun verifySharedText(previewView: ViewGroup?) {
- assertThat(previewView).isNotNull()
- val textContentView = previewView?.findViewById<TextView>(R.id.content_preview_text)
- assertThat(textContentView).isNotNull()
- assertThat(textContentView?.text).isEqualTo(SHARED_TEXT)
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
deleted file mode 100644
index b5fd1fa6..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
+++ /dev/null
@@ -1,366 +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.intentresolver.contentpreview
-
-import android.content.ContentResolver
-import android.graphics.Bitmap
-import android.net.Uri
-import android.util.Size
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import androidx.lifecycle.testing.TestLifecycleOwner
-import com.android.intentresolver.any
-import com.android.intentresolver.anyOrNull
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.google.common.truth.Truth.assertThat
-import java.util.ArrayDeque
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit.MILLISECONDS
-import java.util.concurrent.TimeUnit.SECONDS
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.Runnable
-import kotlinx.coroutines.async
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.plus
-import kotlinx.coroutines.sync.Semaphore
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestCoroutineScheduler
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-import kotlinx.coroutines.yield
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class ImagePreviewImageLoaderTest {
- private val imageSize = Size(300, 300)
- private val uriOne = Uri.parse("content://org.package.app/image-1.png")
- private val uriTwo = Uri.parse("content://org.package.app/image-2.png")
- private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
- private val contentResolver =
- mock<ContentResolver> {
- whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap)
- }
- private val lifecycleOwner = TestLifecycleOwner()
- private val dispatcher = UnconfinedTestDispatcher()
- private lateinit var testSubject: ImagePreviewImageLoader
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
- // create test subject after we've updated the lifecycle dispatcher
- testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- Dispatchers.resetMain()
- }
-
- @Test
- fun prePopulate_cachesImagesUpToTheCacheSize() = runTest {
- testSubject.prePopulate(listOf(uriOne, uriTwo))
-
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
-
- testSubject(uriOne)
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- }
-
- @Test
- fun invoke_returnCachedImageWhenCalledTwice() = runTest {
- testSubject(uriOne)
- testSubject(uriOne)
-
- verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
- }
-
- @Test
- fun invoke_whenInstructed_doesNotCache() = runTest {
- testSubject(uriOne, false)
- testSubject(uriOne, false)
-
- verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
- }
-
- @Test
- fun invoke_overlappedRequests_Deduplicate() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- scheduler.advanceUntilIdle()
- }
-
- verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
- }
-
- @Test
- fun invoke_oldRecordsEvictedFromTheCache() = runTest {
- testSubject(uriOne)
- testSubject(uriTwo)
- testSubject(uriTwo)
- testSubject(uriOne)
-
- verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
- verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
- }
-
- @Test
- fun invoke_doNotCacheNulls() = runTest {
- whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
- testSubject(uriOne)
- testSubject(uriOne)
-
- verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
- }
-
- @Test(expected = CancellationException::class)
- fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- testSubject(uriOne)
- }
-
- @Test(expected = CancellationException::class)
- fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- scheduler.advanceUntilIdle()
- deferred.await()
- }
- }
-
- @Test
- fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- launch(start = UNDISPATCHED) { testSubject(uriOne, true) }
- scheduler.advanceUntilIdle()
- }
- testSubject(uriOne, true)
-
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- }
-
- @Test
- fun invoke_semaphoreGuardsContentResolverCalls() = runTest {
- val contentResolver =
- mock<ContentResolver> {
- whenever(loadThumbnail(any(), any(), anyOrNull()))
- .thenThrow(SecurityException("test"))
- }
- val acquireCount = AtomicInteger()
- val releaseCount = AtomicInteger()
- val testSemaphore =
- object : Semaphore {
- override val availablePermits: Int
- get() = error("Unexpected invocation")
-
- override suspend fun acquire() {
- acquireCount.getAndIncrement()
- }
-
- override fun tryAcquire(): Boolean {
- error("Unexpected invocation")
- }
-
- override fun release() {
- releaseCount.getAndIncrement()
- }
- }
-
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- testSemaphore,
- )
- testSubject(uriOne, false)
-
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- assertThat(acquireCount.get()).isEqualTo(1)
- assertThat(releaseCount.get()).isEqualTo(1)
- }
-
- @Test
- fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest {
- val semaphoreDeferred = CompletableDeferred<Unit>()
- val releaseCount = AtomicInteger()
- val testSemaphore =
- object : Semaphore {
- override val availablePermits: Int
- get() = error("Unexpected invocation")
-
- override suspend fun acquire() {
- semaphoreDeferred.await()
- }
-
- override fun tryAcquire(): Boolean {
- error("Unexpected invocation")
- }
-
- override fun release() {
- releaseCount.getAndIncrement()
- }
- }
-
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- testSemaphore,
- )
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
-
- verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
-
- semaphoreDeferred.complete(Unit)
-
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- assertThat(releaseCount.get()).isEqualTo(1)
- }
-
- @Test
- fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() {
- val requestCount = 4
- val thumbnailCallsCdl = CountDownLatch(requestCount)
- val pendingThumbnailCalls = ArrayDeque<CountDownLatch>()
- val contentResolver =
- mock<ContentResolver> {
- whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer {
- val latch = CountDownLatch(1)
- synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) }
- thumbnailCallsCdl.countDown()
- latch.await()
- bitmap
- }
- }
- val name = "LoadImage"
- val maxSimultaneousRequests = 2
- val threadsStartedCdl = CountDownLatch(requestCount)
- val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name),
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- maxSimultaneousRequests,
- )
- runTest {
- repeat(requestCount) {
- launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) }
- }
- yield()
- // wait for all requests to be dispatched
- assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue()
-
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
-
- pendingThumbnailCalls.poll()?.countDown()
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
-
- pendingThumbnailCalls.poll()?.countDown()
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
- for (cdl in pendingThumbnailCalls) {
- cdl.countDown()
- }
- }
- }
-}
-
-private class NewThreadDispatcher(
- private val coroutineName: String,
- private val launchedCallback: () -> Unit
-) : CoroutineDispatcher() {
- override fun isDispatchNeeded(context: CoroutineContext): Boolean = true
-
- override fun dispatch(context: CoroutineContext, block: Runnable) {
- Thread {
- if (coroutineName == context[CoroutineName.Key]?.name) {
- launchedCallback()
- }
- block.run()
- }
- .start()
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
deleted file mode 100644
index 6599baa9..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
+++ /dev/null
@@ -1,349 +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.intentresolver.contentpreview
-
-import android.content.ContentInterface
-import android.content.Intent
-import android.database.MatrixCursor
-import android.media.MediaMetadata
-import android.net.Uri
-import android.provider.DocumentsContract
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.google.common.truth.Truth.assertThat
-import kotlin.coroutines.EmptyCoroutineContext
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-import org.mockito.Mockito.any
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class PreviewDataProviderTest {
- private val contentResolver = mock<ContentInterface>()
- private val mimeTypeClassifier = DefaultMimeTypeClassifier
- private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
-
- @Test
- fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() {
- val targetIntent = Intent(Intent.ACTION_VIEW)
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
- verify(contentResolver, never()).getType(any())
- }
-
- @Test
- fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/notes.txt")
- val targetIntent =
- Intent(Intent.ACTION_SEND).apply {
- putExtra(Intent.EXTRA_STREAM, uri)
- type = "text/plain"
- }
- whenever(contentResolver.getType(uri)).thenReturn("text/plain")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() {
- val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" }
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
- verify(contentResolver, never()).getType(any())
- }
-
- @Test
- fun test_sendSingleImage_resolvesToImagePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/image.png")
- val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
- whenever(contentResolver.getType(uri)).thenReturn("image/png")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_sendSingleNonImage_resolvesToFilePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/paper.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
- whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isNull()
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/image.png")
- val targetIntent =
- Intent(Intent.ACTION_SEND).apply {
- type = "image/png"
- putExtra(Intent.EXTRA_STREAM, uri)
- }
- whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure"))
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isNull()
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/image.png")
- val targetIntent =
- Intent(Intent.ACTION_SEND).apply {
- type = "image/png"
- putExtra(Intent.EXTRA_STREAM, uri)
- }
- whenever(contentResolver.getStreamTypes(uri, "*/*"))
- .thenThrow(SecurityException("test failure"))
- whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null))
- .thenThrow(SecurityException("test failure"))
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isNull()
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() {
- val uri = Uri.parse("content://org.pkg.app/paper.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
- whenever(contentResolver.getStreamTypes(uri, "*/*"))
- .thenReturn(arrayOf("application/pdf", "image/png"))
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() {
- testMetadataToImagePreview(
- columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
- values =
- arrayOf(
- DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or
- DocumentsContract.Document.FLAG_SUPPORTS_METADATA
- )
- )
- }
-
- @Test
- fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() {
- testMetadataToImagePreview(
- columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
- values = arrayOf("content://org.pkg.app/test.pdf?thumbnail"),
- )
- }
-
- private fun testMetadataToImagePreview(columns: Array<String>, values: Array<Any>) {
- val uri = Uri.parse("content://org.pkg.app/test.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
- whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
- whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null))
- .thenReturn(MatrixCursor(columns).apply { addRow(values) })
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(1)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
- assertThat(testSubject.firstFileInfo?.previewUri).isNotNull()
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_multipleImageUri_useImagePreviewUi() {
- val uri1 = Uri.parse("content://org.pkg.app/test.png")
- val uri2 = Uri.parse("content://org.pkg.app/test.jpg")
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
- whenever(contentResolver.getType(uri1)).thenReturn("image/png")
- whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(2)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
- assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
- // preview type can be determined by the first URI type
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_SomeImageUri_useImagePreviewUi() {
- val uri1 = Uri.parse("content://org.pkg.app/test.png")
- val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
- whenever(contentResolver.getType(uri1)).thenReturn("image/png")
- whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(2)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
- assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
- // preview type can be determined by the first URI type
- verify(contentResolver, times(1)).getType(any())
- }
-
- @Test
- fun test_someNonImageUriWithPreview_useImagePreviewUi() {
- val uri1 = Uri.parse("content://org.pkg.app/test.mp4")
- val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
- whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4")
- whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png"))
- whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
- assertThat(testSubject.uriCount).isEqualTo(2)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
- assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
- verify(contentResolver, times(2)).getType(any())
- }
-
- @Test
- fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() {
- val uri1 = Uri.parse("content://org.pkg.app/test.html")
- val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
- whenever(contentResolver.getType(uri1)).thenReturn("text/html")
- whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
- assertThat(testSubject.uriCount).isEqualTo(2)
- assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
- assertThat(testSubject.firstFileInfo?.previewUri).isNull()
- verify(contentResolver, times(2)).getType(any())
- }
-
- @Test
- fun test_imagePreviewFileInfoFlow_dataLoadedOnce() =
- testScope.runTest {
- val uri1 = Uri.parse("content://org.pkg.app/test.html")
- val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
- val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
- whenever(contentResolver.getType(uri1)).thenReturn("text/html")
- whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
- whenever(contentResolver.getStreamTypes(uri1, "*/*"))
- .thenReturn(arrayOf("text/html", "image/jpeg"))
- whenever(contentResolver.getStreamTypes(uri2, "*/*"))
- .thenReturn(arrayOf("application/pdf", "image/png"))
- val testSubject =
- PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
-
- val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList()
- val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList()
-
- assertThat(fileInfoListOne).hasSize(2)
- assertThat(fileInfoListOne).containsAtLeastElementsIn(fileInfoListTwo).inOrder()
-
- verify(contentResolver, times(1)).getType(uri1)
- verify(contentResolver, times(1)).getStreamTypes(uri1, "*/*")
- verify(contentResolver, times(1)).getType(uri2)
- verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*")
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
deleted file mode 100644
index e7de0b7b..00000000
--- a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
+++ /dev/null
@@ -1,166 +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.intentresolver.contentpreview
-
-import android.net.Uri
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
-import com.android.intentresolver.R.layout.chooser_grid
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
-import kotlin.coroutines.EmptyCoroutineContext
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.takeWhile
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@RunWith(AndroidJUnit4::class)
-class UnifiedContentPreviewUiTest {
- private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
- private val actionFactory =
- mock<ChooserContentPreviewUi.ActionFactory> {
- whenever(createCustomActions()).thenReturn(emptyList())
- }
- private val imageLoader = mock<ImageLoader>()
- private val headlineGenerator =
- mock<HeadlineGenerator> {
- whenever(getImagesHeadline(anyInt())).thenReturn("Image Headline")
- whenever(getVideosHeadline(anyInt())).thenReturn("Video Headline")
- whenever(getFilesHeadline(anyInt())).thenReturn("Files Headline")
- }
-
- private val context
- get() = getInstrumentation().getContext()
-
- @Test
- fun test_displayImagesWithoutUriMetadata_showImagesHeadline() {
- testLoadingHeadline("image/*", files = null)
-
- verify(headlineGenerator, times(1)).getImagesHeadline(2)
- }
-
- @Test
- fun test_displayVideosWithoutUriMetadata_showImagesHeadline() {
- testLoadingHeadline("video/*", files = null)
-
- verify(headlineGenerator, times(1)).getVideosHeadline(2)
- }
-
- @Test
- fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() {
- testLoadingHeadline("application/pdf", files = null)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(2)
- }
-
- @Test
- fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() {
- testLoadingHeadline("*/*", files = null)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(2)
- }
-
- @Test
- fun test_displayImagesWithUriMetadataSet_showImagesHeadline() {
- val uri = Uri.parse("content://pkg.app/image.png")
- val files =
- listOf(
- FileInfo.Builder(uri).withMimeType("image/png").build(),
- FileInfo.Builder(uri).withMimeType("image/jpeg").build(),
- )
- testLoadingHeadline("image/*", files)
-
- verify(headlineGenerator, times(1)).getImagesHeadline(2)
- }
-
- @Test
- fun test_displayVideosWithUriMetadataSet_showImagesHeadline() {
- val uri = Uri.parse("content://pkg.app/image.png")
- val files =
- listOf(
- FileInfo.Builder(uri).withMimeType("video/mp4").build(),
- FileInfo.Builder(uri).withMimeType("video/mp4").build(),
- )
- testLoadingHeadline("video/*", files)
-
- verify(headlineGenerator, times(1)).getVideosHeadline(2)
- }
-
- @Test
- fun test_displayImagesAndVideosWithUriMetadataSet_showImagesHeadline() {
- val uri = Uri.parse("content://pkg.app/image.png")
- val files =
- listOf(
- FileInfo.Builder(uri).withMimeType("image/png").build(),
- FileInfo.Builder(uri).withMimeType("video/mp4").build(),
- )
- testLoadingHeadline("*/*", files)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(2)
- }
-
- @Test
- fun test_displayDocumentsWithUriMetadataSet_showImagesHeadline() {
- val uri = Uri.parse("content://pkg.app/image.png")
- val files =
- listOf(
- FileInfo.Builder(uri).withMimeType("application/pdf").build(),
- FileInfo.Builder(uri).withMimeType("application/pdf").build(),
- )
- testLoadingHeadline("application/pdf", files)
-
- verify(headlineGenerator, times(1)).getFilesHeadline(2)
- }
-
- private fun testLoadingHeadline(intentMimeType: String, files: List<FileInfo>?) {
- testScope.runTest {
- val endMarker = FileInfo.Builder(Uri.EMPTY).build()
- val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1)
- val testSubject =
- UnifiedContentPreviewUi(
- testScope,
- /*isSingleImage=*/ false,
- intentMimeType,
- actionFactory,
- imageLoader,
- DefaultMimeTypeClassifier,
- object : TransitionElementStatusCallback {
- override fun onTransitionElementReady(name: String) = Unit
- override fun onAllTransitionElementsReady() = Unit
- },
- files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker },
- /*itemCount=*/ 2,
- headlineGenerator
- )
- val layoutInflater = LayoutInflater.from(context)
- val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup
-
- testSubject.display(context.resources, LayoutInflater.from(context), gridLayout)
- emptySourceFlow.tryEmit(endMarker)
- }
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
deleted file mode 100644
index 9b4a8057..00000000
--- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
+++ /dev/null
@@ -1,482 +0,0 @@
-/*
- * Copyright (C) 2022 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.intentresolver.shortcuts
-
-import android.app.prediction.AppPredictor
-import android.content.ComponentName
-import android.content.Context
-import android.content.IntentFilter
-import android.content.pm.ApplicationInfo
-import android.content.pm.PackageManager
-import android.content.pm.PackageManager.ApplicationInfoFlags
-import android.content.pm.ShortcutManager
-import android.os.UserHandle
-import android.os.UserManager
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.testing.TestLifecycleOwner
-import androidx.test.filters.SmallTest
-import com.android.intentresolver.any
-import com.android.intentresolver.argumentCaptor
-import com.android.intentresolver.capture
-import com.android.intentresolver.chooser.DisplayResolveInfo
-import com.android.intentresolver.createAppTarget
-import com.android.intentresolver.createShareShortcutInfo
-import com.android.intentresolver.createShortcutInfo
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
-import java.util.function.Consumer
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestCoroutineScheduler
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.atLeastOnce
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-@OptIn(ExperimentalCoroutinesApi::class)
-@SmallTest
-class ShortcutLoaderTest {
- private val appInfo =
- ApplicationInfo().apply {
- enabled = true
- flags = 0
- }
- private val pm =
- mock<PackageManager> {
- whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo)
- }
- private val userManager =
- mock<UserManager> {
- whenever(isUserRunning(any<UserHandle>())).thenReturn(true)
- whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true)
- whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false)
- }
- private val context =
- mock<Context> {
- whenever(packageManager).thenReturn(pm)
- whenever(createContextAsUser(any(), anyInt())).thenReturn(this)
- whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
- }
- private val scheduler = TestCoroutineScheduler()
- private val dispatcher = UnconfinedTestDispatcher(scheduler)
- private val lifecycleOwner = TestLifecycleOwner()
- private val intentFilter = mock<IntentFilter>()
- private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
- private val callback = mock<Consumer<ShortcutLoader.Result>>()
- private val componentName = ComponentName("pkg", "Class")
- private val appTarget =
- mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) }
- private val appTargets = arrayOf(appTarget)
- private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1)
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- Dispatchers.resetMain()
- }
-
- @Test
- fun test_loadShortcutsWithAppPredictor_resultIntegrity() {
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(appTargets)
-
- val matchingAppTarget = createAppTarget(matchingShortcutInfo)
- val shortcuts =
- listOf(
- matchingAppTarget,
- // an AppTarget that does not belong to any resolved application; should be ignored
- createAppTarget(
- createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
- )
- )
- val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
- verify(appPredictor, atLeastOnce())
- .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor))
- appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts)
-
- val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
- verify(callback, times(1)).accept(capture(resultCaptor))
-
- val result = resultCaptor.value
- assertTrue("An app predictor result is expected", result.isFromAppPredictor)
- assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
- assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
- assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
- for (shortcut in result.shortcutsByApp[0].shortcuts) {
- assertEquals(
- "Wrong AppTarget in the cache",
- matchingAppTarget,
- result.directShareAppTargetCache[shortcut]
- )
- assertEquals(
- "Wrong ShortcutInfo in the cache",
- matchingShortcutInfo,
- result.directShareShortcutInfoCache[shortcut]
- )
- }
- }
-
- @Test
- fun test_loadShortcutsWithShortcutManager_resultIntegrity() {
- val shortcutManagerResult =
- listOf(
- ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
- // mismatching shortcut
- createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
- )
- val shortcutManager =
- mock<ShortcutManager> {
- whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
- }
- whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- null,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(appTargets)
-
- val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
- verify(callback, times(1)).accept(capture(resultCaptor))
-
- val result = resultCaptor.value
- assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
- assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
- assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
- assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
- for (shortcut in result.shortcutsByApp[0].shortcuts) {
- assertTrue(
- "AppTargets are not expected the cache of a ShortcutManager result",
- result.directShareAppTargetCache.isEmpty()
- )
- assertEquals(
- "Wrong ShortcutInfo in the cache",
- matchingShortcutInfo,
- result.directShareShortcutInfoCache[shortcut]
- )
- }
- }
-
- @Test
- fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() {
- val shortcutManagerResult =
- listOf(
- ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
- // mismatching shortcut
- createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
- )
- val shortcutManager =
- mock<ShortcutManager> {
- whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
- }
- whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(appTargets)
-
- verify(appPredictor, times(1)).requestPredictionUpdate()
- val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
- verify(appPredictor, times(1))
- .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor))
- appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList())
-
- val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
- verify(callback, times(1)).accept(capture(resultCaptor))
-
- val result = resultCaptor.value
- assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
- assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
- assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
- assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
- for (shortcut in result.shortcutsByApp[0].shortcuts) {
- assertTrue(
- "AppTargets are not expected the cache of a ShortcutManager result",
- result.directShareAppTargetCache.isEmpty()
- )
- assertEquals(
- "Wrong ShortcutInfo in the cache",
- matchingShortcutInfo,
- result.directShareShortcutInfoCache[shortcut]
- )
- }
- }
-
- @Test
- fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() {
- val shortcutManagerResult =
- listOf(
- ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
- // mismatching shortcut
- createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
- )
- val shortcutManager =
- mock<ShortcutManager> {
- whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
- }
- whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
- whenever(appPredictor.requestPredictionUpdate())
- .thenThrow(IllegalStateException("Test exception"))
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(appTargets)
-
- verify(appPredictor, times(1)).requestPredictionUpdate()
-
- val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
- verify(callback, times(1)).accept(capture(resultCaptor))
-
- val result = resultCaptor.value
- assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
- assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
- assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
- assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
- for (shortcut in result.shortcutsByApp[0].shortcuts) {
- assertTrue(
- "AppTargets are not expected the cache of a ShortcutManager result",
- result.directShareAppTargetCache.isEmpty()
- )
- assertEquals(
- "Wrong ShortcutInfo in the cache",
- matchingShortcutInfo,
- result.directShareShortcutInfoCache[shortcut]
- )
- }
- }
-
- @Test
- fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() {
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- verify(appPredictor, times(1)).requestPredictionUpdate()
- verify(callback, never()).accept(any())
- }
-
- @Test
- fun test_ShortcutLoader_noResultsWithoutAppTargets() {
- val shortcutManagerResult =
- listOf(
- ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
- // mismatching shortcut
- createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
- )
- val shortcutManager =
- mock<ShortcutManager> {
- whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
- }
- whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- null,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- verify(shortcutManager, times(1)).getShareTargets(any())
- verify(callback, never()).accept(any())
-
- testSubject.reset()
-
- verify(shortcutManager, times(2)).getShareTargets(any())
- verify(callback, never()).accept(any())
-
- testSubject.updateAppTargets(appTargets)
-
- verify(shortcutManager, times(2)).getShareTargets(any())
- verify(callback, times(1)).accept(any())
- }
-
- @Test
- fun test_OnLifecycleDestroyed_unsubscribeFromAppPredictor() {
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- UserHandle.of(0),
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- verify(appPredictor, never()).unregisterPredictionUpdates(any())
-
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
-
- verify(appPredictor, times(1)).unregisterPredictionUpdates(any())
- }
-
- @Test
- fun test_workProfileNotRunning_doNotCallServices() {
- testDisabledWorkProfileDoNotCallSystem(isUserRunning = false)
- }
-
- @Test
- fun test_workProfileLocked_doNotCallServices() {
- testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false)
- }
-
- @Test
- fun test_workProfileQuiteModeEnabled_doNotCallServices() {
- testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true)
- }
-
- @Test
- fun test_mainProfileNotRunning_callServicesAnyway() {
- testAlwaysCallSystemForMainProfile(isUserRunning = false)
- }
-
- @Test
- fun test_mainProfileLocked_callServicesAnyway() {
- testAlwaysCallSystemForMainProfile(isUserUnlocked = false)
- }
-
- @Test
- fun test_mainProfileQuiteModeEnabled_callServicesAnyway() {
- testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true)
- }
-
- private fun testDisabledWorkProfileDoNotCallSystem(
- isUserRunning: Boolean = true,
- isUserUnlocked: Boolean = true,
- isQuietModeEnabled: Boolean = false
- ) {
- val userHandle = UserHandle.of(10)
- with(userManager) {
- whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
- whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
- whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
- }
- whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
- val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
- val callback = mock<Consumer<ShortcutLoader.Result>>()
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- userHandle,
- false,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock()))
-
- verify(appPredictor, never()).requestPredictionUpdate()
- }
-
- private fun testAlwaysCallSystemForMainProfile(
- isUserRunning: Boolean = true,
- isUserUnlocked: Boolean = true,
- isQuietModeEnabled: Boolean = false
- ) {
- val userHandle = UserHandle.of(10)
- with(userManager) {
- whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
- whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
- whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
- }
- whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
- val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
- val callback = mock<Consumer<ShortcutLoader.Result>>()
- val testSubject =
- ShortcutLoader(
- context,
- lifecycleOwner.lifecycle,
- appPredictor,
- userHandle,
- true,
- intentFilter,
- dispatcher,
- callback
- )
-
- testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock()))
-
- verify(appPredictor, times(1)).requestPredictionUpdate()
- }
-}
diff --git a/lint-baseline.xml b/lint-baseline.xml
new file mode 100644
index 00000000..c1f51348
--- /dev/null
+++ b/lint-baseline.xml
@@ -0,0 +1,2425 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.4.0-alpha08" type="baseline" client="" dependencies="true" name="" variant="all" version="8.4.0-alpha08">
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="421"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="431"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="517"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="526"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="722"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="733"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainThreadHandler())) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1684"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainThreadHandler().post(() -> {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="2199"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" context.getMainExecutor(),"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="192"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" }, getApplicationContext().getMainExecutor());"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java"
+ line="161"
+ column="44"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="297"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="307"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="374"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" getMainLooper(),"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="383"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedMainThread"
+ message="Replace with injected `@Main Executor`."
+ errorLine1=" runnable -> context.getMainThreadHandler().post(runnable));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="127"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="WrongCommentType"
+ message="This block comment looks like it was intended to be a javadoc comment"
+ errorLine1=" * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="821"
+ column="8"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The ui layer may not depend on the data layer."
+ errorLine1="import com.android.intentresolver.data.model.ANDROID_APP_SCHEME"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/model/ActivityModel.kt"
+ line="23"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The ui layer may not depend on the data layer."
+ errorLine1="import com.android.intentresolver.data.model.ChooserRequest"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt"
+ line="45"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The ui layer may not depend on the data layer."
+ errorLine1="import com.android.intentresolver.data.model.ChooserRequest"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt"
+ line="25"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The ui layer may not depend on the data layer."
+ errorLine1="import com.android.intentresolver.data.repository.ChooserRequestRepository"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt"
+ line="26"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The ui layer may not depend on the data layer."
+ errorLine1="import com.android.intentresolver.data.repository.DevicePolicyResources"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt"
+ line="21"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The domain layer may not depend on the ui layer."
+ errorLine1="import com.android.intentresolver.ui.viewmodel.readAlternateIntents"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt"
+ line="40"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="CleanArchitectureDependencyViolation"
+ message="The domain layer may not depend on the ui layer."
+ errorLine1="import com.android.intentresolver.ui.viewmodel.readChooserActions"
+ errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt"
+ line="41"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" (UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE));"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/AbstractResolverComparator.java"
+ line="136"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" .getSystemService(AppPredictionManager::class.java)"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt"
+ line="66"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" (UserManager) context.getSystemService(Context.USER_SERVICE);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="289"
+ column="47"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" getContext().getSystemService(LauncherApps.class).pinShortcuts("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="226"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" List&lt;ShortcutManager.ShareShortcutInfo> targets = contextAsUser.getSystemService("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="233"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" .getSystemService(ACTIVITY_SERVICE);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="279"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt"
+ line="47"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return getSystemService(DevicePolicyManager.class).getResources().getString("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java"
+ line="165"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return getSystemService(DevicePolicyManager.class).getResources().getString("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java"
+ line="171"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return getSystemService(UserManager.class);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java"
+ line="402"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" LauncherApps launcherApps = context.getSystemService(LauncherApps.class);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java"
+ line="100"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return mContext.getSystemService(DevicePolicyManager.class).getResources().getString("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java"
+ line="127"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return mContext.getSystemService(DevicePolicyManager.class).getResources().getString("
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java"
+ line="135"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" (UserManager) mContext.getSystemService(Context.USER_SERVICE);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="503"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt"
+ line="77"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" selectedProfileContext.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager?"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt"
+ line="209"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java"
+ line="98"
+ column="62"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" return requireNotNull(context.getSystemService(serviceType.java))"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/repository/UserScopedService.kt"
+ line="65"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="NonInjectedService"
+ message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`"
+ errorLine1=" String title = mContext.getSystemService(DevicePolicyManager.class)"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java"
+ line="83"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a GlobalSettings instead"
+ errorLine1=" return Settings.Global.getInt(getContentResolver(),"
+ errorLine2=" ~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java"
+ line="280"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a SecureSettings instead"
+ errorLine1=" return Settings.Secure.getString(resolver, name)"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt"
+ line="32"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a SecureSettings instead"
+ errorLine1=" return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull()"
+ errorLine2=" ~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt"
+ line="36"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a SecureSettings instead"
+ errorLine1=" return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull()"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt"
+ line="40"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a SecureSettings instead"
+ errorLine1=" return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull()"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt"
+ line="44"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="StaticSettingsProvider"
+ message="`@Inject` a SecureSettings instead"
+ errorLine1=" return Settings.Secure.getString(resolver, name)"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SecureSettings.kt"
+ line="25"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="CanvasSize"
+ message="Calling `Canvas.getWidth()` is usually wrong; you should be calling `getWidth()` instead"
+ errorLine1=" int xPos = canvas.getWidth() / 2;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/RoundedRectImageView.java"
+ line="134"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="CanvasSize"
+ message="Calling `Canvas.getHeight()` is usually wrong; you should be calling `getHeight()` instead"
+ errorLine1=" int yPos = (int) ((canvas.getHeight() / 2.0f)"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/RoundedRectImageView.java"
+ line="135"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="CustomViewStyleable"
+ message="By convention, the declare-styleable (`ResolverDrawerLayout_LayoutParams`) for a layout parameter class (`LayoutParams`) is expected to be the surrounding class (`ResolverDrawerLayout`) plus &quot;`_Layout`&quot;, e.g. `ResolverDrawerLayout_Layout`. (Various editor features rely on this convention.)"
+ errorLine1=" R.styleable.ResolverDrawerLayout_LayoutParams);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java"
+ line="1222"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="InconsistentLayout"
+ message="The id &quot;edit&quot; in layout &quot;image_preview_image_item&quot; is missing from the following layout configurations: layout (present in layout-h480dp)"
+ errorLine1=" android:id=&quot;@+id/edit&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml"
+ line="58"
+ column="9"
+ message="Occurrence in layout-h480dp"/>
+ </issue>
+
+ <issue
+ id="MissingConstraints"
+ message="This view is not constrained vertically: at runtime it will jump to the top unless you add a vertical constraint"
+ errorLine1=" &lt;TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_headline_row.xml"
+ line="27"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="MissingConstraints"
+ message="This view is not constrained horizontally: at runtime it will jump to the left unless you add a horizontal constraint"
+ errorLine1=" &lt;com.android.intentresolver.widget.RoundedRectImageView"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml"
+ line="24"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
+ errorLine1=" R.layout.resolver_different_item_header, null, false);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1197"
+ column="62"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
+ errorLine1=" ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false)"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="123"
+ column="88"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
+ errorLine1=" : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="124"
+ column="83"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
+ errorLine1=" R.layout.resolver_different_item_header, null, false);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="853"
+ column="62"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
+ errorLine1=" R.layout.resolver_list_per_profile, null, false),"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java"
+ line="80"
+ column="69"/>
+ </issue>
+
+ <issue
+ id="ManifestOrder"
+ message="`&lt;uses-sdk>` tag appears after `&lt;application>` tag"
+ errorLine1=" &lt;uses-sdk android:minSdkVersion=&quot;VanillaIceCream&quot; android:targetSdkVersion=&quot;16&quot;/>"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="./out/soong/.intermediates/packages/modules/IntentResolver/IntentResolver-core/android_common/e18b8e8d84cb9f664aa09a397b08c165/manifest_fixer/AndroidManifest.xml"
+ line="22"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="MissingInflatedId"
+ message="`@layout/chooser_dialog` does not contain a declaration with id `title`"
+ errorLine1=" TextView title = v.findViewById(com.android.internal.R.id.title);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="133"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="MissingInflatedId"
+ message="`@layout/chooser_dialog` does not contain a declaration with id `icon`"
+ errorLine1=" ImageView icon = v.findViewById(com.android.internal.R.id.icon);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="134"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="MissingInflatedId"
+ message="`@layout/chooser_dialog` does not contain a declaration with id `listContainer`"
+ errorLine1=" RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java"
+ line="135"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="437"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="559"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="761"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="766"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="771"
+ column="68"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="900"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" .getActiveListAdapter().getFilteredItem()))"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="909"
+ column="38"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1133"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1136"
+ column="66"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1155"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1206"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1217"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1483"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults("
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1623"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1699"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1724"
+ column="62"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1731"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1801"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1838"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getCurrentUserHandle());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1886"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mChooserMultiProfilePagerAdapter.getCurrentUserHandle());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1914"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" .getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="1981"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals("
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="2041"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="2288"
+ column="75"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java"
+ line="2351"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" final ViewHolder vh = (ViewHolder) v.getTag();"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="414"
+ column="23"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" final ViewHolder vh = (ViewHolder) v.getTag();"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="414"
+ column="40"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" vh.text.setLines(2);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="415"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" vh.text.setHorizontallyScrolling(false);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="416"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" vh.text2.setVisibility(View.GONE);"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="417"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private void resetViewHolder(ViewHolder holder) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="432"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.reset();"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="433"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.itemView.setBackground(holder.defaultItemViewBackground);"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="434"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.itemView.setBackground(holder.defaultItemViewBackground);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="434"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ((BadgeTextView) holder.text).setBadgeDrawable(null);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="437"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setBackground(null);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="439"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setPaddingRelative(0, 0, 0, 0);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="440"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private void updateContentDescription(ViewHolder holder, String description) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="443"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.itemView.setContentDescription(description);"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="444"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private void bindPlaceholder(ViewHolder holder) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="447"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.itemView.setBackground(null);"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="448"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private void bindGroupIndicator(ViewHolder holder, Drawable indicator) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="451"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ((BadgeTextView) holder.text).setBadgeDrawable(indicator);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="453"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="455"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setBackground(indicator);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="456"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="460"
+ column="38"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="461"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" holder.text.setBackground(indicator);"
+ errorLine2=" ~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="462"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="115"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" getActiveListAdapter().notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="135"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" getPageAdapterForIndex(i).setFooterHeight(height);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="150"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ChooserGridAdapter adapter = getPageAdapterForIndex(i);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java"
+ line="157"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" /* instance_id = 3 */ mInstanceId.getId(),"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java"
+ line="96"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" /* instance_id = 3 */ mInstanceId.getId(),"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java"
+ line="118"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" /* instance_id = 3 */ mInstanceId.getId(),"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java"
+ line="142"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" /* instance_id = 3 */ mInstanceId.getId(),"
+ errorLine2=" ~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java"
+ line="200"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This class should only be accessed from tests or within private scope"
+ errorLine1=" private final ResolverListAdapter.ViewHolder mWrappedViewHolder;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ItemViewHolder.java"
+ line="36"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ItemViewHolder.java"
+ line="46"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="313"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" &amp;&amp; mMultiProfilePagerAdapter.getActiveListAdapter() != null) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="323"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="324"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="415"
+ column="62"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="540"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="560"
+ column="76"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="572"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="582"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="596"
+ column="47"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" &amp;&amp; mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="627"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" final int N = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="713"
+ column="57"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="720"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="729"
+ column="63"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" set[N] = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="737"
+ column="56"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="739"
+ column="77"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="761"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" .mResolverListController.setLastChosen(intent, filter, bestMatch);"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="762"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="868"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1143"
+ column="47"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1146"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1172"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" .getActiveListAdapter().getFilteredItem()))"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1181"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1217"
+ column="40"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ri = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1233"
+ column="44"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1326"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1334"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1399"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo);"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1399"
+ column="66"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1472"
+ column="47"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1534"
+ column="47"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1539"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1544"
+ column="61"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1687"
+ column="75"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1754"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1795"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1828"
+ column="56"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java"
+ line="1896"
+ column="68"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" return mResolverListController.getScore(target);"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="212"
+ column="40"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mResolverListController.addResolveListDedupe(currentResolveList,"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="329"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mResolverListController.filterIneligibleActivities(currentResolveList, true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="362"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" return mResolverListController.filterLowPriority("
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="384"
+ column="40"/>
+ </issue>
+
+ <issue
+ id="VisibleForTests"
+ message="This method should only be accessed from tests or within private scope"
+ errorLine1=" mLastChosen = mResolverListController.getLastChosen();"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java"
+ line="410"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="SupportAnnotationUsage"
+ message="This annotation does not apply for type java.lang.Object; expected int"
+ errorLine1=" @ContentPreviewType"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt"
+ line="230"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="ExpiredTargetSdkVersion"
+ message="Google Play requires that apps target API level 33 or higher."
+ errorLine1=" &lt;uses-sdk android:minSdkVersion=&quot;VanillaIceCream&quot; android:targetSdkVersion=&quot;16&quot;/>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="./out/soong/.intermediates/packages/modules/IntentResolver/IntentResolver-core/android_common/e18b8e8d84cb9f664aa09a397b08c165/manifest_fixer/AndroidManifest.xml"
+ line="22"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="BindServiceOnMainThread"
+ message="This method should be annotated with `@WorkerThread` because it calls unbindService"
+ errorLine1=" mContext.unbindService(mConnection);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java"
+ line="308"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="BindServiceOnMainThread"
+ message="This method should be annotated with `@WorkerThread` because it calls bindServiceAsUser"
+ errorLine1=" context.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE, UserHandle.SYSTEM);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java"
+ line="333"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="139"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java"
+ line="145"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt"
+ line="94"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged()"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt"
+ line="316"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="RegisterReceiverViaContext"
+ message="Register `BroadcastReceiver` using `BroadcastDispatcher` instead of `Context`"
+ errorLine1=" context.registerReceiverAsUser("
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt"
+ line="63"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="RegisterReceiverViaContext"
+ message="Register `BroadcastReceiver` using `BroadcastDispatcher` instead of `Context`"
+ errorLine1=" context.registerReceiverAsUser("
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java"
+ line="74"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="SharedFlowCreation"
+ message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics"
+ errorLine1=" MutableSharedFlow&lt;FileInfo>(replay = records.size).apply {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt"
+ line="91"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SharedFlowCreation"
+ message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics"
+ errorLine1=" val reportFlow = MutableSharedFlow&lt;Any>(replay = 2)"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt"
+ line="660"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="SharedFlowCreation"
+ message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics"
+ errorLine1=" MutableSharedFlow&lt;Array&lt;DisplayResolveInfo>?>("
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt"
+ line="82"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SharedFlowCreation"
+ message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics"
+ errorLine1=" MutableSharedFlow&lt;ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt"
+ line="87"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SlowUserIdQuery"
+ message="Use `UserTracker.getUserId()` instead of `ActivityManager.getCurrentUser()`"
+ errorLine1=" userHandle == UserHandle.of(ActivityManager.getCurrentUser()),"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt"
+ line="104"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="SlowUserInfoQuery"
+ message="Use `UserTracker.getUserInfo()` instead of `UserManager.getUserInfo()`"
+ errorLine1=" val originUserInfo = userManager.getUserInfo(contentUserHint)"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarding.kt"
+ line="51"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="SlowUserInfoQuery"
+ message="Use `UserTracker.getUserInfo()` instead of `UserManager.getUserInfo()`"
+ errorLine1=" withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) }"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/repository/UserRepository.kt"
+ line="267"
+ column="61"/>
+ </issue>
+
+ <issue
+ id="SoftwareBitmap"
+ message="Replace software bitmap with `Config.HARDWARE`"
+ errorLine1=" mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8);"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java"
+ line="172"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="SoftwareBitmap"
+ message="Replace software bitmap with `Config.HARDWARE`"
+ errorLine1=" bitmap.getHeight(), Bitmap.Config.ARGB_8888);"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java"
+ line="297"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="SoftwareBitmap"
+ message="Replace software bitmap with `Config.HARDWARE`"
+ errorLine1=" Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java"
+ line="343"
+ column="71"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `LinearLayout`: `layout_alignParentTop`"
+ errorLine1=" android:layout_alignParentTop=&quot;true&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_scrollable_preview.xml"
+ line="99"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `LinearLayout`: `layout_centerHorizontal`"
+ errorLine1=" android:layout_centerHorizontal=&quot;true&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_scrollable_preview.xml"
+ line="100"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This field leaks a context object"
+ errorLine1=" protected final Context mContext;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java"
+ line="29"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (anonymous android.os.AsyncTask)"
+ errorLine1=" new AsyncTask&lt;Void, Void, List&lt;DisplayResolveInfo>>() {"
+ errorLine2=" ^">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java"
+ line="488"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This field leaks a context object"
+ errorLine1=" private final Context mContext;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/LoadLabelTask.java"
+ line="32"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `&lt;TextView/>` and a compound drawable"
+ errorLine1=" &lt;LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml"
+ line="29"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@androidprv:color/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)"
+ errorLine1=" android:background=&quot;@androidprv:color/materialColorSurfaceContainer&quot;>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_file.xml"
+ line="27"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@androidprv:color/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)"
+ errorLine1=" android:background=&quot;@androidprv:color/materialColorSurfaceContainer&quot;>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_files_text.xml"
+ line="26"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@androidprv:color/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)"
+ errorLine1=" android:background=&quot;@androidprv:color/materialColorSurfaceContainer&quot;>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_image.xml"
+ line="27"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@androidprv:color/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)"
+ errorLine1=" android:background=&quot;@androidprv:color/materialColorSurfaceContainer&quot;>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_text.xml"
+ line="28"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" &lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/drawable/chooser_direct_share_icon_placeholder.xml"
+ line="20"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" xmlns:aapt=&quot;http://schemas.android.com/aapt&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/drawable/chooser_direct_share_icon_placeholder.xml"
+ line="21"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" &lt;LinearLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml"
+ line="40"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolver_list_per_profile.xml"
+ line="23"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedNamespace"
+ message="Unused namespace declaration xmlns:android; already declared on the root element"
+ errorLine1=" &lt;LinearLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml"
+ line="40"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnusedNamespace"
+ message="Unused namespace declaration xmlns:android; already declared on the root element"
+ errorLine1=" xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolver_list_per_profile.xml"
+ line="23"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace &quot;...&quot; with ellipsis character (…, &amp;#8230;) ?"
+ errorLine1=" &lt;string name=&quot;whichApplication&quot; msgid=&quot;2309561338625872614&quot;>&quot;... በመጠቀም ድርጊቱን አጠናቅ&quot;&lt;/string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/values-am/strings.xml"
+ line="19"
+ column="65"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace &quot;...&quot; with ellipsis character (…, &amp;#8230;) ?"
+ errorLine1=" &lt;string name=&quot;whichApplication&quot; msgid=&quot;2309561338625872614&quot;>&quot;Wykonaj czynność przez...&quot;&lt;/string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/values-pl/strings.xml"
+ line="19"
+ column="65"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace &quot;...&quot; with ellipsis character (…, &amp;#8230;) ?"
+ errorLine1=" &lt;string name=&quot;whichViewApplication&quot; msgid=&quot;7660051361612888119&quot;>&quot;...ဖြင့် ဖွင့်မည်&quot;&lt;/string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/values-my/strings.xml"
+ line="22"
+ column="69"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace &quot;...&quot; with ellipsis character (…, &amp;#8230;) ?"
+ errorLine1=" &lt;string name=&quot;whichEditApplication&quot; msgid=&quot;5097563012157950614&quot;>&quot;...နှင့် တည်းဖြတ်ရန်&quot;&lt;/string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/values-my/strings.xml"
+ line="30"
+ column="69"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace &quot;...&quot; with ellipsis character (…, &amp;#8230;) ?"
+ errorLine1=" &lt;string name=&quot;whichSendToApplication&quot; msgid=&quot;2724450540348806267&quot;>&quot;Sūtīšana, izmantojot...&quot;&lt;/string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/values-lv/strings.xml"
+ line="36"
+ column="71"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view `ResolverDrawerLayout` overrides `onTouchEvent` but not `performClick`"
+ errorLine1=" public boolean onTouchEvent(MotionEvent ev) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java"
+ line="403"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView android:id=&quot;@android:id/icon&quot;"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml"
+ line="37"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView android:id=&quot;@android:id/icon&quot;"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_dialog_item.xml"
+ line="30"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView android:id=&quot;@android:id/icon&quot;"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_item.xml"
+ line="32"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_file.xml"
+ line="47"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_text.xml"
+ line="112"
+ column="8"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/image_preview_image_item.xml"
+ line="43"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml"
+ line="46"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml"
+ line="68"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/miniresolver.xml"
+ line="39"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView android:id=&quot;@android:id/icon&quot;"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolve_grid_item.xml"
+ line="32"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView android:id=&quot;@android:id/icon&quot;"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml"
+ line="30"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolver_list_with_default.xml"
+ line="44"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" &lt;ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/resolver_list_with_default.xml"
+ line="79"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string &quot;App name&quot;, should use `@string` resource"
+ errorLine1=" android:text=&quot;App name&quot;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml"
+ line="46"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@androidprv:id/button_open` can overlap `@androidprv:id/use_same_profile_browser` if @string/activity_resolver_use_once, @string/whichViewApplicationLabel grow due to localized text expansion"
+ errorLine1=" &lt;Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="packages/modules/IntentResolver/java/res/layout/miniresolver.xml"
+ line="100"
+ column="14"/>
+ </issue>
+
+</issues>
diff --git a/proguard.flags b/proguard.flags
index 5541c3ff..c0b7f21e 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -1,2 +1,5 @@
# Class referenced from xml drawable
--keep class com.android.intentresolver.SimpleIconFactory$FixedScaleDrawable
+# TODO(b/373579455): Evaluate if <init> needs to be kept.
+-keep class com.android.intentresolver.SimpleIconFactory$FixedScaleDrawable {
+ void <init>();
+}
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..a3f90554
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,33 @@
+# Automated testing
+
+IntentResolver test code is organized into sub-modules by scope and purpose.
+
+TreeHugger execution is controlled via [TEST_MAPPING](../TEST_MAPPING).
+
+## [Unit Tests](unit)
+
+Instrumentation tests which run on devices or emulators, but are otherwise isolated from the system. Scope of verification is limited to a single component at a time. These tests are extremely fast and should provide the most detailed and granular failure information.
+
+**Use cases**: The first choice for all new code. Fakes and other reusable test code should be placed in [shared](shared).
+
+## [Integration Tests](integration)
+
+Emulator tests which verify operation of the foundational components backed by android platform APIs. These tests are required for coverage because components tested here are replaced with fakes in other test suites.
+
+**Use cases**: Larger tests which require device preparation and setup to test production code using real dependencies. Implement these when verification is needed of interactions with live system services or applications using real data.
+
+## [Activity Tests](activity)
+
+Instrumentation tests which launch target activity code directly in the instrumentation context. These operate mostly production code end to end and provide a blend of UI assertions and verification using injected mocks and fakes.
+
+Originally from `frameworks/base/core/tests`, these cover the widest range of code but are historically the most flaky, brittle and with the least informative failures.
+
+Use Hilt's [@TestInstallIn](https://developer.android.com/training/dependency-injection/hilt-testing) to replace dependencies with alternates as needed. Test modules should be added here, while the fakes and other utilities used in these tests are found in [tests/shared](shared).
+
+**Use cases**: New tests and expansion of existing tests should be considered only as last resort for otherwise untestable code.
+
+## [Shared](shared)
+
+Testing code as a common dependency available to all the above test types.
+
+**Use cases**: Fakes, reusable assertions, or other test setup code. Tests for code here should be placed in [tests/unit](unit).
diff --git a/tests/activity/Android.bp b/tests/activity/Android.bp
new file mode 100644
index 00000000..2e66a84d
--- /dev/null
+++ b/tests/activity/Android.bp
@@ -0,0 +1,69 @@
+//
+// 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 {
+ default_team: "trendy_team_capture_and_share",
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "IntentResolver-tests-activity",
+ manifest: "AndroidManifest.xml",
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ ],
+
+ libs: [
+ "android.test.runner.stubs",
+ "android.test.base.stubs",
+ "android.test.mock.stubs",
+ "framework",
+ "framework-res",
+ ],
+
+ resource_dirs: ["res"],
+ test_config: "AndroidTest.xml",
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.ext.truth",
+ "androidx.test.espresso.contrib",
+ "androidx.test.espresso.core",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "androidx.lifecycle_lifecycle-common-java8",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.lifecycle_lifecycle-runtime-testing",
+ "hilt_android_testing",
+ "IntentResolver-core",
+ "IntentResolver-tests-shared",
+ "junit",
+ "kotlinx_coroutines_test",
+ "mockito-target-minus-junit4",
+ "mockito-kotlin-nodeps",
+ "testables",
+ "truth",
+ "flag-junit",
+ "platform-test-annotations",
+ ],
+ plugins: ["dagger2-compiler"],
+ test_suites: ["general-tests"],
+ sdk_version: "core_platform",
+ min_sdk_version: "current",
+ target_sdk_version: "current",
+ platform_apis: true,
+}
diff --git a/java/tests/AndroidManifest.xml b/tests/activity/AndroidManifest.xml
index 05830c4c..00dbd78d 100644
--- a/java/tests/AndroidManifest.xml
+++ b/tests/activity/AndroidManifest.xml
@@ -13,11 +13,8 @@
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.intentresolver.tests">
-
- <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" />
+ package="com.android.intentresolver.tests">
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
<uses-permission android:name="android.permission.QUERY_USERS"/>
@@ -25,19 +22,20 @@
<uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG"/>
<uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
- <application android:name="com.android.intentresolver.TestApplication">
+ <application android:name="dagger.hilt.android.testing.HiltTestApplication">
<uses-library android:name="android.test.runner" />
<activity android:name="com.android.intentresolver.ChooserWrapperActivity" />
<activity android:name="com.android.intentresolver.ResolverWrapperActivity" />
+ <activity android:name="com.android.intentresolver.ChooserWrapperActivity" />
+ <activity android:name="com.android.intentresolver.ResolverWrapperActivity" />
<provider
android:authorities="com.android.intentresolver.tests"
android:name="com.android.intentresolver.TestContentProvider"
android:grantUriPermissions="true" />
</application>
- <instrumentation android:name="android.testing.TestableInstrumentation"
- android:targetPackage="com.android.intentresolver.tests"
- android:label="Tests for IntentResolver">
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.intentresolver.tests">
</instrumentation>
</manifest>
diff --git a/tests/activity/AndroidTest.xml b/tests/activity/AndroidTest.xml
new file mode 100644
index 00000000..04e4e69f
--- /dev/null
+++ b/tests/activity/AndroidTest.xml
@@ -0,0 +1,33 @@
+<?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.
+-->
+<configuration description="Run IntentResolver Tests.">
+ <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+ <option name="cleanup-apks" value="true" />
+ <option name="test-file-name" value="IntentResolver-tests-activity.apk" />
+ </target_preparer>
+
+ <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+ <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+ <option name="run-command" value="wm dismiss-keyguard" />
+ </target_preparer>
+
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+ <option name="package" value="com.android.intentresolver.tests" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+ <option name="instrumentation-arg" key="thisisignored" value="thisisignored --no-window-animation" />
+ <option name="hidden-api-checks" value="false"/>
+ </test>
+</configuration>
diff --git a/java/tests/res/drawable/test320x240.png b/tests/activity/res/drawable/test320x240.png
index 9b5800da..9b5800da 100644
--- a/java/tests/res/drawable/test320x240.png
+++ b/tests/activity/res/drawable/test320x240.png
Binary files differ
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java
index 84f5124c..311201cf 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
+++ b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java
@@ -21,23 +21,19 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.os.UserHandle;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
import com.android.intentresolver.chooser.TargetInfo;
-import com.android.intentresolver.contentpreview.ImageLoader;
-import com.android.intentresolver.flags.FeatureFlagRepository;
-import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
import com.android.intentresolver.shortcuts.ShortcutLoader;
+import kotlin.jvm.functions.Function2;
+
import java.util.function.Consumer;
import java.util.function.Function;
-import kotlin.jvm.functions.Function2;
-
/**
* Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing.
* We cannot directly mock the activity created since instrumentation creates it, so instead we use
@@ -52,82 +48,36 @@ public class ChooserActivityOverrideData {
}
return sInstance;
}
-
- @SuppressWarnings("Since15")
- public Function<PackageManager, PackageManager> createPackageManager;
public Function<TargetInfo, Boolean> onSafelyStartInternalCallback;
public Function<TargetInfo, Boolean> onSafelyStartCallback;
public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader>
shortcutLoaderFactory = (userHandle, callback) -> null;
- public ChooserActivity.ChooserListController resolverListController;
- public ChooserActivity.ChooserListController workResolverListController;
+ public ChooserListController resolverListController;
+ public ChooserListController workResolverListController;
public Boolean isVoiceInteraction;
public Cursor resolverCursor;
public boolean resolverForceException;
- public ImageLoader imageLoader;
- public EventLog mEventLog;
- public int alternateProfileSetting;
public Resources resources;
- public UserHandle workProfileUserHandle;
- public UserHandle cloneProfileUserHandle;
- public UserHandle tabOwnerUserHandleForLaunch;
public boolean hasCrossProfileIntents;
public boolean isQuietModeEnabled;
public Integer myUserId;
- public WorkProfileAvailabilityManager mWorkProfileAvailability;
public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
- public PackageManager packageManager;
- public FeatureFlagRepository featureFlagRepository;
public void reset() {
onSafelyStartInternalCallback = null;
isVoiceInteraction = null;
- createPackageManager = null;
- imageLoader = null;
resolverCursor = null;
resolverForceException = false;
- resolverListController = mock(ChooserActivity.ChooserListController.class);
- workResolverListController = mock(ChooserActivity.ChooserListController.class);
- mEventLog = mock(EventLog.class);
- alternateProfileSetting = 0;
+ resolverListController = mock(ChooserListController.class);
+ workResolverListController = mock(ChooserListController.class);
resources = null;
- workProfileUserHandle = null;
- cloneProfileUserHandle = null;
- tabOwnerUserHandleForLaunch = null;
hasCrossProfileIntents = true;
isQuietModeEnabled = false;
myUserId = null;
- packageManager = null;
- mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) {
- @Override
- public boolean isQuietModeEnabled() {
- return isQuietModeEnabled;
- }
-
- @Override
- public boolean isWorkProfileUserUnlocked() {
- return true;
- }
-
- @Override
- public void requestQuietModeEnabled(boolean enabled) {
- isQuietModeEnabled = enabled;
- }
-
- @Override
- public void markWorkProfileEnabledBroadcastReceived() {}
-
- @Override
- public boolean isWaitingToEnableWorkProfile() {
- return false;
- }
- };
shortcutLoaderFactory = ((userHandle, resultConsumer) -> null);
-
mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class);
when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt()))
.thenAnswer(invocation -> hasCrossProfileIntents);
- featureFlagRepository = null;
}
private ChooserActivityOverrideData() {}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java
index b8b57403..e103e57b 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java
@@ -25,6 +25,7 @@ import static androidx.test.espresso.action.ViewActions.swipeUp;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.hasSibling;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
@@ -39,6 +40,7 @@ import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCOR
import static com.android.intentresolver.MatcherUtils.first;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
import static junit.framework.Assert.assertNull;
@@ -47,10 +49,9 @@ import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
@@ -82,20 +83,30 @@ import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
+import android.graphics.Typeface;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Bundle;
import android.os.UserHandle;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.provider.DeviceConfig;
+import android.provider.Settings;
import android.service.chooser.ChooserAction;
import android.service.chooser.ChooserTarget;
-import android.util.HashedStringCache;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.UnderlineSpan;
import android.util.Pair;
import android.util.SparseArray;
import android.view.View;
import android.view.WindowManager;
+import android.widget.TextView;
-import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
@@ -107,12 +118,36 @@ import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.contentpreview.FakeThumbnailLoader;
import com.android.intentresolver.contentpreview.ImageLoader;
+import com.android.intentresolver.contentpreview.ImageLoaderModule;
+import com.android.intentresolver.contentpreview.PreviewCacheSize;
+import com.android.intentresolver.contentpreview.PreviewMaxConcurrency;
+import com.android.intentresolver.contentpreview.ThumbnailLoader;
+import com.android.intentresolver.contentpreview.ThumbnailSize;
+import com.android.intentresolver.data.repository.FakeUserRepository;
+import com.android.intentresolver.data.repository.UserRepository;
+import com.android.intentresolver.data.repository.UserRepositoryModule;
+import com.android.intentresolver.ext.RecyclerViewExt;
+import com.android.intentresolver.inject.ApplicationUser;
+import com.android.intentresolver.inject.PackageManagerModule;
+import com.android.intentresolver.inject.ProfileParent;
import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.logging.FakeEventLog;
+import com.android.intentresolver.platform.AppPredictionAvailable;
+import com.android.intentresolver.platform.AppPredictionModule;
+import com.android.intentresolver.platform.GlobalSettings;
+import com.android.intentresolver.platform.ImageEditor;
+import com.android.intentresolver.platform.ImageEditorModule;
+import com.android.intentresolver.shared.model.User;
import com.android.intentresolver.shortcuts.ShortcutLoader;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.systemui.flags.BooleanFlag;
+
+import dagger.hilt.android.qualifiers.ApplicationContext;
+import dagger.hilt.android.testing.BindValue;
+import dagger.hilt.android.testing.HiltAndroidRule;
+import dagger.hilt.android.testing.HiltAndroidTest;
+import dagger.hilt.android.testing.UninstallModules;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
@@ -121,200 +156,164 @@ import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
-import org.junit.rules.RuleChain;
-import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
-import java.util.function.Function;
+
+import javax.inject.Inject;
/**
- * Instrumentation tests for the IntentResolver module's Sharesheet (ChooserActivity).
- * TODO: remove methods that supported running these tests against arbitrary ChooserActivity
- * subclasses. Those were left over from an earlier version where IntentResolver's ChooserActivity
- * inherited from the framework version at com.android.internal.app.ChooserActivity, and this test
- * file inherited from the framework's version as well. Once the migration to the IntentResolver
- * package is complete, that aspect of the test design can revert to match the style of the
- * framework tests prior to ag/16482932.
- * TODO: this can simply be renamed to "ChooserActivityTest" if that's ever unambiguous (i.e., if
- * there's no risk of confusion with the framework tests that currently share the same name).
+ * Instrumentation tests for ChooserActivity.
+ * <p>
+ * Legacy test suite migrated from framework CoreTests.
*/
+@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@RunWith(Parameterized.class)
-public class UnbundledChooserActivityTest {
-
- /* --------
- * Subclasses should copy the following section verbatim (or alternatively could specify some
- * additional @Parameterized.Parameters, as long as the correct parameters are used to
- * initialize the ChooserActivityTest). The subclasses should also be @RunWith the
- * `Parameterized` runner.
- * --------
- */
+@HiltAndroidTest
+@UninstallModules({
+ AppPredictionModule.class,
+ ImageEditorModule.class,
+ PackageManagerModule.class,
+ ImageLoaderModule.class,
+ UserRepositoryModule.class,
+})
+public class ChooserActivityTest {
- private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser();
- private static final Function<PackageManager, PackageManager> DEFAULT_PM = pm -> pm;
- private static final Function<PackageManager, PackageManager> NO_APP_PREDICTION_SERVICE_PM =
- pm -> {
- PackageManager mock = Mockito.spy(pm);
- when(mock.getAppPredictionServicePackageName()).thenReturn(null);
- return mock;
- };
-
- private static final List<BooleanFlag> ALL_FLAGS =
- Arrays.asList();
-
- private static final Map<BooleanFlag, Boolean> ALL_FLAGS_OFF =
- createAllFlagsOverride(false);
- private static final Map<BooleanFlag, Boolean> ALL_FLAGS_ON =
- createAllFlagsOverride(true);
-
- @Parameterized.Parameters
- public static Collection packageManagers() {
- if (ALL_FLAGS.isEmpty()) {
- // No flags to toggle between, so just two configurations.
- return Arrays.asList(new Object[][] {
- // Default PackageManager and all flags off
- { DEFAULT_PM, ALL_FLAGS_OFF},
- // No App Prediction Service and all flags off
- { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF },
- });
- }
- return Arrays.asList(new Object[][] {
- // Default PackageManager and all flags off
- { DEFAULT_PM, ALL_FLAGS_OFF},
- // Default PackageManager and all flags on
- { DEFAULT_PM, ALL_FLAGS_ON},
- // No App Prediction Service and all flags off
- { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF },
- // No App Prediction Service and all flags on
- { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON }
- });
- }
-
- private static Map<BooleanFlag, Boolean> createAllFlagsOverride(boolean value) {
- HashMap<BooleanFlag, Boolean> overrides = new HashMap<>(ALL_FLAGS.size());
- for (BooleanFlag flag : ALL_FLAGS) {
- overrides.put(flag, value);
- }
- return overrides;
+ private static FakeEventLog getEventLog(ChooserWrapperActivity activity) {
+ return (FakeEventLog) activity.mEventLog;
}
- /* --------
- * Subclasses can override the following methods to customize test behavior.
- * --------
- */
-
- /**
- * Perform any necessary per-test initialization steps (subclasses may add additional steps
- * before and/or after calling up to the superclass implementation).
- */
- @CallSuper
- protected void setup() {
- // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
- // permissions we require (which we'll read from the manifest at runtime).
- InstrumentationRegistry
- .getInstrumentation()
- .getUiAutomation()
- .adoptShellPermissionIdentity();
+ private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
+ .getInstrumentation().getTargetContext().getUser();
- cleanOverrideData();
- ChooserActivityOverrideData.getInstance().featureFlagRepository =
- new TestFeatureFlagRepository(mFlags);
- }
+ private static final User PERSONAL_USER =
+ new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL);
- /**
- * Given an intent that was constructed in a test, perform any additional configuration to
- * specify the appropriate concrete ChooserActivity subclass. The activity launched by this
- * intent must descend from android.intentresolver.ChooserActivity (for our ActivityTestRule), and
- * must also implement the android.intentresolver.IChooserWrapper interface (since test code will
- * assume the ability to make unsafe downcasts).
- */
- protected Intent getConcreteIntentForLaunch(Intent clientIntent) {
- clientIntent.setClass(
- InstrumentationRegistry.getInstrumentation().getTargetContext(),
- com.android.intentresolver.ChooserWrapperActivity.class);
- return clientIntent;
- }
-
- /**
- * Whether {@code #testIsAppPredictionServiceAvailable} should verify the behavior after
- * changing the availability conditions at runtime. In the unbundled chooser, the availability
- * is cached at start and will never be re-evaluated.
- * TODO: remove when we no longer want to test the system's on-the-fly evaluation.
- */
- protected boolean shouldTestTogglingAppPredictionServiceAvailabilityAtRuntime() {
- return false;
- }
+ private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10);
- /* --------
- * The code in this section is unorthodox and can be simplified/reverted when we no longer need
- * to support the parallel chooser implementations.
- * --------
- */
+ private static final User WORK_USER =
+ new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK);
- @Rule
- public final TestRule mRule;
+ private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11);
- // Shared test code references the activity under test as ChooserActivity, the common ancestor
- // of any (inheritance-based) chooser implementation. For testing purposes, that activity will
- // usually be cast to IChooserWrapper to expose instrumentation.
- private ActivityTestRule<ChooserActivity> mActivityRule =
- new ActivityTestRule<>(ChooserActivity.class, false, false) {
- @Override
- public ChooserActivity launchActivity(Intent clientIntent) {
- return super.launchActivity(getConcreteIntentForLaunch(clientIntent));
- }
- };
+ private static final User CLONE_USER =
+ new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE);
- @Before
- public final void doPolymorphicSetup() {
- // The base class needs a @Before-annotated setup for when it runs against the system
- // chooser, while subclasses need to be able to specify their own setup behavior. Notably
- // the unbundled chooser, running in user-space, needs to take additional steps before it
- // can run #cleanOverrideData() (which writes to DeviceConfig).
- setup();
+ @Parameters(name = "appPrediction={0}")
+ public static Iterable<?> parameters() {
+ return Arrays.asList(
+ /* appPredictionAvailable = */ true,
+ /* appPredictionAvailable = */ false
+ );
}
- /* --------
- * Subclasses can ignore the remaining code and inherit the full suite of tests.
- * --------
- */
-
private static final String TEST_MIME_TYPE = "application/TestType";
private static final int CONTENT_PREVIEW_IMAGE = 1;
private static final int CONTENT_PREVIEW_FILE = 2;
private static final int CONTENT_PREVIEW_TEXT = 3;
- private final Function<PackageManager, PackageManager> mPackageManagerOverride;
- private final Map<BooleanFlag, Boolean> mFlags;
+ @Rule(order = 0)
+ public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+ @Rule(order = 1)
+ public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this);
+
+ @Rule(order = 2)
+ public ActivityTestRule<ChooserWrapperActivity> mActivityRule =
+ new ActivityTestRule<>(ChooserWrapperActivity.class, false, false);
+
+ @Inject
+ @ApplicationContext
+ Context mContext;
+
+ @Inject
+ GlobalSettings mGlobalSettings;
+
+ /** An arbitrary pre-installed activity that handles this type of intent. */
+ @BindValue
+ @ImageEditor
+ final Optional<ComponentName> mImageEditor = Optional.ofNullable(
+ ComponentName.unflattenFromString("com.google.android.apps.messaging/"
+ + ".ui.conversationlist.ShareIntentActivity"));
+
+ /** Whether an AppPredictionService is available for use. */
+ @BindValue
+ @AppPredictionAvailable
+ final boolean mAppPredictionAvailable;
+
+ @BindValue
+ PackageManager mPackageManager;
+
+ /** "launchedAs" */
+ @BindValue
+ @ApplicationUser
+ UserHandle mApplicationUser = PERSONAL_USER_HANDLE;
+
+ @BindValue
+ @ProfileParent
+ UserHandle mProfileParent = PERSONAL_USER_HANDLE;
+ private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER));
- public UnbundledChooserActivityTest(
- Function<PackageManager, PackageManager> packageManagerOverride,
- Map<BooleanFlag, Boolean> flags) {
- mPackageManagerOverride = packageManagerOverride;
- mFlags = flags;
+ @BindValue
+ final UserRepository mUserRepository = mFakeUserRepo;
- mRule = RuleChain
- .outerRule(new FeatureFlagRule(flags))
- .around(mActivityRule);
+ private final FakeImageLoader mFakeImageLoader = new FakeImageLoader();
+
+ @BindValue
+ final ImageLoader mImageLoader = mFakeImageLoader;
+
+ @BindValue
+ @PreviewCacheSize
+ int mPreviewCacheSize = 16;
+
+ @BindValue
+ @PreviewMaxConcurrency
+ int mPreviewMaxConcurrency = 4;
+
+ @BindValue
+ @ThumbnailSize
+ int mPreviewThumbnailSize = 500;
+
+ @BindValue
+ ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader();
+
+ @Before
+ public void setUp() {
+ // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
+ // permissions we require (which we'll read from the manifest at runtime).
+ InstrumentationRegistry
+ .getInstrumentation()
+ .getUiAutomation()
+ .adoptShellPermissionIdentity();
+
+ cleanOverrideData();
+
+ // Assign @Inject fields
+ mHiltAndroidRule.inject();
+
+ // Populate @BindValue dependencies using injected values. These fields contribute
+ // values to the dependency graph at activity launch time. This allows replacing
+ // arbitrary bindings per-test case if needed.
+ mPackageManager = mContext.getPackageManager();
+ }
+
+ public ChooserActivityTest(boolean appPredictionAvailable) {
+ mAppPredictionAvailable = appPredictionAvailable;
}
private void setDeviceConfigProperty(
@@ -337,13 +336,18 @@ public class UnbundledChooserActivityTest {
public void cleanOverrideData() {
ChooserActivityOverrideData.getInstance().reset();
- ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride;
setDeviceConfigProperty(
SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
Boolean.toString(true));
}
+ private static PackageManager createFakePackageManager(ResolveInfo resolveInfo) {
+ PackageManager packageManager = mock(PackageManager.class);
+ when(packageManager.resolveActivity(any(Intent.class), any())).thenReturn(resolveInfo);
+ return packageManager;
+ }
+
@Test
public void customTitle() throws InterruptedException {
Intent viewIntent = createViewTextIntent();
@@ -384,6 +388,58 @@ public class UnbundledChooserActivityTest {
}
@Test
+ public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() {
+ CharSequence title = new SpannableStringBuilder()
+ .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
+ .append(
+ "Title",
+ new ForegroundColorSpan(Color.RED),
+ Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ CharSequence sharedText = new SpannableStringBuilder()
+ .append(
+ "Rich",
+ new BackgroundColorSpan(Color.YELLOW),
+ Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
+ .append(
+ "Text",
+ new StyleSpan(Typeface.ITALIC),
+ Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
+ Intent sendIntent = createSendTextIntent();
+ sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
+ sendIntent.putExtra(Intent.EXTRA_TITLE, title);
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+ setupResolverControllers(resolvedComponentInfos);
+
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ waitForIdle();
+
+ onView(withId(com.android.internal.R.id.content_preview_title))
+ .check((view, e) -> {
+ assertThat(view).isInstanceOf(TextView.class);
+ CharSequence text = ((TextView) view).getText();
+ assertThat(text).isInstanceOf(Spanned.class);
+ Spanned spanned = (Spanned) text;
+ assertThat(spanned.getSpans(0, spanned.length(), Object.class))
+ .hasLength(2);
+ assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1);
+ assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class))
+ .hasLength(1);
+ });
+
+ onView(withId(com.android.internal.R.id.content_preview_text))
+ .check((view, e) -> {
+ assertThat(view).isInstanceOf(TextView.class);
+ CharSequence text = ((TextView) view).getText();
+ assertThat(text).isInstanceOf(Spanned.class);
+ Spanned spanned = (Spanned) text;
+ assertThat(spanned.getSpans(0, spanned.length(), Object.class))
+ .hasLength(2);
+ assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1);
+ assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1);
+ });
+ }
+
+ @Test
public void emptyPreviewTitleAndThumbnail() throws InterruptedException {
Intent sendIntent = createSendTextIntentWithPreview(null, null);
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -431,14 +487,13 @@ public class UnbundledChooserActivityTest {
}
@Test
- public void visiblePreviewTitleAndThumbnail() throws InterruptedException {
+ public void visiblePreviewTitleAndThumbnail() {
String previewTitle = "My Content Preview Title";
Uri uri = Uri.parse(
"android.resource://com.android.frameworks.coretests/"
- + R.drawable.test320x240);
+ + com.android.intentresolver.tests.R.drawable.test320x240);
Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -547,8 +602,8 @@ public class UnbundledChooserActivityTest {
};
ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
DisplayResolveInfo testDri =
- activity.createTestDisplayResolveInfo(sendIntent, toChoose, "testLabel", "testInfo",
- sendIntent, /* resolveInfoPresentationGetter */ null);
+ activity.createTestDisplayResolveInfo(
+ sendIntent, toChoose, "testLabel", "testInfo", sendIntent);
onView(withText(toChoose.activityInfo.name))
.perform(click());
waitForIdle();
@@ -607,7 +662,7 @@ public class UnbundledChooserActivityTest {
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0);
Intent sendIntent = createSendTextIntent();
@@ -704,8 +759,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_ExcludeText() {
Uri uri = createTestContentProviderUri(null, "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -746,8 +800,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_RemoveAndAddBackText() {
Uri uri = createTestContentProviderUri("application/pdf", "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
final String text = "https://google.com/search?q=google";
sendIntent.putExtra(Intent.EXTRA_TEXT, text);
@@ -794,8 +847,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
Intent alternativeIntent = createSendTextIntent();
@@ -838,8 +890,6 @@ public class UnbundledChooserActivityTest {
public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -899,15 +949,16 @@ public class UnbundledChooserActivityTest {
setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
onView(withId(R.id.copy)).check(matches(isDisplayed()));
onView(withId(R.id.copy)).perform(click());
-
- EventLog logger = activity.getEventLog();
- verify(logger, times(1)).logActionSelected(eq(EventLog.SELECTION_TYPE_COPY));
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getActionSelected())
+ .isEqualTo(new FakeEventLog.ActionSelected(
+ /* targetType = */ EventLog.SELECTION_TYPE_COPY));
}
@Test
@@ -918,8 +969,7 @@ public class UnbundledChooserActivityTest {
setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
onView(withId(com.android.internal.R.id.chooser_nearby_button))
@@ -929,21 +979,18 @@ public class UnbundledChooserActivityTest {
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
}
-
-
@Test @Ignore
public void testEditImageLogs() {
+
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed()));
@@ -961,8 +1008,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -975,8 +1021,11 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
- assertThat(recyclerView.getAdapter().getItemCount(), is(1));
- assertThat(recyclerView.getChildCount(), is(1));
+ RecyclerViewExt.endAnimations(recyclerView);
+ assertThat("recyclerView adapter item count",
+ recyclerView.getAdapter().getItemCount(), is(1));
+ assertThat("recyclerView child view count",
+ recyclerView.getChildCount(), is(1));
View imageView = recyclerView.getChildAt(0);
Rect rect = new Rect();
boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect);
@@ -997,8 +1046,6 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1009,21 +1056,21 @@ public class UnbundledChooserActivityTest {
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)));
}
- @Test
- public void testSlowUriMetadata_fallbackToFilePreview() throws InterruptedException {
+ @Test(timeout = 4_000)
+ public void testSlowUriMetadata_fallbackToFilePreview() {
Uri uri = createTestContentProviderUri(
- "application/pdf", "image/png", /*streamTypeTimeout=*/4_000);
+ "application/pdf", "image/png", /*streamTypeTimeout=*/8_000);
ArrayList<Uri> uris = new ArrayList<>(1);
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
- assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000))
- .isTrue();
+ // The preview type resolution is expected to timeout and default to file preview, otherwise
+ // the test should timeout.
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed()));
@@ -1031,11 +1078,10 @@ public class UnbundledChooserActivityTest {
onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed()));
}
- @Test
- public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi()
- throws InterruptedException {
+ @Test(timeout = 4_000)
+ public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() {
Uri fileUri = createTestContentProviderUri(
- "application/pdf", "application/pdf", /*streamTypeTimeout=*/150);
+ "application/pdf", "application/pdf", /*streamTypeTimeout=*/300);
Uri imageUri = createTestContentProviderUri("application/pdf", "image/png");
ArrayList<Uri> uris = new ArrayList<>(50);
for (int i = 0; i < 49; i++) {
@@ -1043,13 +1089,13 @@ public class UnbundledChooserActivityTest {
}
uris.add(imageUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(imageUri, createBitmap());
+ mFakeImageLoader.setBitmap(imageUri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
- assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000))
- .isTrue();
+ // The preview type resolution is expected to timeout and default to file preview, otherwise
+ // the test should timeout.
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
@@ -1075,8 +1121,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1094,15 +1139,14 @@ public class UnbundledChooserActivityTest {
});
}
- @Test
- public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart()
- throws InterruptedException {
+ @Test(timeout = 4_000)
+ public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() {
Uri imgOneUri = createTestContentProviderUri("image/png", null);
Uri imgTwoUri = createTestContentProviderUri("image/png", null)
.buildUpon()
.path("image-2.png")
.build();
- Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 3_000);
+ Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 8_000);
ArrayList<Uri> uris = new ArrayList<>(2);
// two large previews to fill the screen and be presented right away and one
// document that would be delayed by the URI metadata reading
@@ -1111,18 +1155,18 @@ public class UnbundledChooserActivityTest {
uris.add(docUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- Map<Uri, Bitmap> bitmaps = new HashMap<>();
- bitmaps.put(imgOneUri, createWideBitmap(Color.RED));
- bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
- bitmaps.put(docUri, createWideBitmap(Color.BLUE));
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(bitmaps);
+ mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED));
+ mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN));
+ mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE));
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
- assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 1_000))
- .isTrue();
+ // the preview type is expected to be resolved quickly based on the first provided URI
+ // metadata. If, instead, it is dependent on the third URI metadata, the test should either
+ // timeout or (more probably due to inner timeout) default to file preview type; anyway the
+ // test will fail.
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
onView(withId(R.id.scrollable_image_preview))
@@ -1131,6 +1175,7 @@ public class UnbundledChooserActivityTest {
throw exception;
}
RecyclerView recyclerView = (RecyclerView) view;
+ RecyclerViewExt.endAnimations(recyclerView);
assertThat(recyclerView.getChildCount()).isAtLeast(1);
// the first view is a preview
View imageView = recyclerView.getChildAt(0).findViewById(R.id.image);
@@ -1161,8 +1206,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1174,6 +1218,49 @@ public class UnbundledChooserActivityTest {
}
@Test
+ public void test_shareImageWithRichText_RichTextIsDisplayed() {
+ final Uri uri = createTestContentProviderUri("image/png", null);
+ final CharSequence sharedText = new SpannableStringBuilder()
+ .append(
+ "text-",
+ new StyleSpan(Typeface.BOLD_ITALIC),
+ Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
+ .append(
+ Long.toString(System.currentTimeMillis()),
+ new ForegroundColorSpan(Color.RED),
+ Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
+
+ ArrayList<Uri> uris = new ArrayList<>();
+ uris.add(uri);
+
+ Intent sendIntent = createSendUriIntentWithPreview(uris);
+ sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
+ mFakeImageLoader.setBitmap(uri, createBitmap());
+
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+ setupResolverControllers(resolvedComponentInfos);
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ waitForIdle();
+ onView(withText(sharedText.toString()))
+ .check(matches(isDisplayed()))
+ .check((view, e) -> {
+ if (e != null) {
+ throw e;
+ }
+ assertThat(view).isInstanceOf(TextView.class);
+ CharSequence text = ((TextView) view).getText();
+ assertThat(text).isInstanceOf(Spanned.class);
+ Spanned spanned = (Spanned) text;
+ Object[] spans = spanned.getSpans(0, text.length(), Object.class);
+ assertThat(spans).hasLength(2);
+ assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1);
+ assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class))
+ .hasLength(1);
+ });
+ }
+
+ @Test
public void testTextPreviewWhenTextIsSharedWithMultipleImages() {
final Uri uri = createTestContentProviderUri("image/png", null);
final String sharedText = "text-" + System.currentTimeMillis();
@@ -1184,8 +1271,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1210,40 +1296,51 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
- EventLog logger = activity.getEventLog();
waitForIdle();
- verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown();
+ assertThat(event).isNotNull();
+ assertThat(event.isWorkProfile()).isFalse();
+ assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE);
}
@Test
public void testOnCreateLoggingFromWorkProfile() {
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- ChooserActivityOverrideData.getInstance().alternateProfileSetting =
- MetricsEvent.MANAGED_PROFILE;
- final IChooserWrapper activity = (IChooserWrapper)
+ // Launch as work user.
+ mFakeUserRepo.addUser(WORK_USER, true);
+ mApplicationUser = WORK_PROFILE_USER_HANDLE;
+
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
- EventLog logger = activity.getEventLog();
waitForIdle();
- verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown();
+ assertThat(event).isNotNull();
+ assertThat(event.isWorkProfile()).isTrue();
+ assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE);
}
@Test
public void testEmptyPreviewLogging() {
Intent sendIntent = createSendTextIntentWithPreview(null, null);
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(
- Intent.createChooser(sendIntent, "empty preview logger test"));
- EventLog logger = activity.getEventLog();
+ ChooserWrapperActivity activity =
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent,
+ "empty preview logger test"));
waitForIdle();
- verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown();
+ assertThat(event).isNotNull();
+ assertThat(event.isWorkProfile()).isFalse();
+ assertThat(event.getTargetMimeType()).isNull();
}
@Test
@@ -1254,13 +1351,14 @@ public class UnbundledChooserActivityTest {
setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- // Second invocation is from onCreate
- EventLog logger = activity.getEventLog();
- Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT));
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getActionShareWithPreview())
+ .isEqualTo(new FakeEventLog.ActionShareWithPreview(
+ /* previewType = */ CONTENT_PREVIEW_TEXT));
}
@Test
@@ -1271,18 +1369,20 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- EventLog logger = activity.getEventLog();
- Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE));
+
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getActionShareWithPreview())
+ .isEqualTo(new FakeEventLog.ActionShareWithPreview(
+ /* previewType = */ CONTENT_PREVIEW_IMAGE));
}
@Test
@@ -1405,8 +1505,7 @@ public class UnbundledChooserActivityTest {
ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
"testLabel",
"testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null);
+ sendIntent);
final ChooserListAdapter adapter = activity.getAdapter();
assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST));
@@ -1431,7 +1530,7 @@ public class UnbundledChooserActivityTest {
createShortcutLoaderFactory();
// Start activity
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
@@ -1481,22 +1580,15 @@ public class UnbundledChooserActivityTest {
.perform(click());
waitForIdle();
- ArgumentCaptor<HashedStringCache.HashResult> hashCaptor =
- ArgumentCaptor.forClass(HashedStringCache.HashResult.class);
- verify(activity.getEventLog(), times(1)).logShareTargetSelected(
- eq(EventLog.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- /* directTargetAlsoRanked= */ eq(-1),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ hashCaptor.capture(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
- String hashedName = hashCaptor.getValue().hashedString;
- assertThat(
- "Hash is not predictable but must be obfuscated",
- hashedName, is(not(name)));
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getShareTargetSelected()).hasSize(1);
+ FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0);
+ assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE);
+ assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1);
+ var hashResult = call.getDirectTargetHashed();
+ var hash = hashResult == null ? "" : hashResult.hashedString;
+ assertWithMessage("Hash is not predictable but must be obfuscated")
+ .that(hash).isNotEqualTo(name);
}
// This test is too long and too slow and should not be taken as an example for future tests.
@@ -1512,7 +1604,7 @@ public class UnbundledChooserActivityTest {
createShortcutLoaderFactory();
// Start activity
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
@@ -1564,16 +1656,12 @@ public class UnbundledChooserActivityTest {
.perform(click());
waitForIdle();
- verify(activity.getEventLog(), times(1)).logShareTargetSelected(
- eq(EventLog.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- /* directTargetAlsoRanked= */ eq(0),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ any(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getShareTargetSelected()).hasSize(1);
+ FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0);
+
+ assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE);
+ assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0);
}
@Test
@@ -1733,7 +1821,7 @@ public class UnbundledChooserActivityTest {
// We need app targets for direct targets to get displayed
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
// set caller-provided target
Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
@@ -1835,11 +1923,13 @@ public class UnbundledChooserActivityTest {
broadcastInvoked.countDown();
}
};
- testContext.registerReceiver(testReceiver, new IntentFilter(testAction));
+ testContext.registerReceiver(testReceiver, new IntentFilter(testAction),
+ Context.RECEIVER_EXPORTED);
try {
onView(withText(customActionLabel)).perform(click());
- broadcastInvoked.await();
+ assertTrue("Timeout waiting for broadcast",
+ broadcastInvoked.await(5000, TimeUnit.MILLISECONDS));
} finally {
testContext.unregisterReceiver(testReceiver);
}
@@ -1875,11 +1965,14 @@ public class UnbundledChooserActivityTest {
broadcastInvoked.countDown();
}
};
- testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction));
+ testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction),
+ Context.RECEIVER_EXPORTED);
try {
onView(withText(label)).perform(click());
- broadcastInvoked.await();
+ assertTrue("Timeout waiting for broadcast",
+ broadcastInvoked.await(5000, TimeUnit.MILLISECONDS));
+
} finally {
testContext.unregisterReceiver(testReceiver);
}
@@ -1940,19 +2033,18 @@ public class UnbundledChooserActivityTest {
ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE);
// Start activity
- final IChooserWrapper wrapper = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
// Insert the direct share target
Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
directShareToShortcutInfos.put(serviceTargets.get(0), null);
InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> wrapper.getAdapter().addServiceResults(
- wrapper.createTestDisplayResolveInfo(sendIntent,
+ () -> activity.getAdapter().addServiceResults(
+ activity.createTestDisplayResolveInfo(sendIntent,
ri,
"testLabel",
"testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
+ sendIntent),
serviceTargets,
TARGET_TYPE_CHOOSER_TARGET,
directShareToShortcutInfos,
@@ -1962,11 +2054,11 @@ public class UnbundledChooserActivityTest {
assertThat(
String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)",
appTargetsExpected + 16, appTargetsExpected),
- wrapper.getAdapter().getCount(), is(appTargetsExpected + 16));
+ activity.getAdapter().getCount(), is(appTargetsExpected + 16));
assertThat("Chooser should have exactly one selectable direct target",
- wrapper.getAdapter().getSelectableServiceTargetCount(), is(1));
+ activity.getAdapter().getSelectableServiceTargetCount(), is(1));
assertThat("The resolver info must match the resolver info used to create the target",
- wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri));
+ activity.getAdapter().getItem(0).getResolveInfo(), is(ri));
// Click on the direct target
String name = serviceTargets.get(0).getTitle().toString();
@@ -1974,25 +2066,23 @@ public class UnbundledChooserActivityTest {
.perform(click());
waitForIdle();
- EventLog logger = wrapper.getEventLog();
- verify(logger, times(1)).logShareTargetSelected(
- eq(EventLog.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- // The packages sholdn't match for app target and direct target:
- /* directTargetAlsoRanked= */ eq(-1),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ any(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ var invocations = eventLog.getShareTargetSelected();
+ assertWithMessage("Only one ShareTargetSelected event logged")
+ .that(invocations).hasSize(1);
+ FakeEventLog.ShareTargetSelected call = invocations.get(0);
+ assertWithMessage("targetType should be SELECTION_TYPE_SERVICE")
+ .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE);
+ assertWithMessage(
+ "The packages shouldn't match for app target and direct target")
+ .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1);
}
@Test
public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
@@ -2024,7 +2114,7 @@ public class UnbundledChooserActivityTest {
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
final IChooserWrapper activity = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
@@ -2039,7 +2129,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
@@ -2060,7 +2150,7 @@ public class UnbundledChooserActivityTest {
@Test @Ignore
public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
int workProfileTargets = 4;
@@ -2091,7 +2181,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
@@ -2115,13 +2205,13 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_workProfileDisabled_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(workProfileTargets);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -2139,7 +2229,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
List<ResolvedComponentInfo> workResolvedComponentInfos =
@@ -2159,16 +2249,50 @@ public class UnbundledChooserActivityTest {
.check(matches(isDisplayed()));
}
+ @Test
+ public void testWorkTab_previewIsScrollable() {
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(300);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(3);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+
+ Uri uri = createTestContentProviderUri("image/png", null);
+
+ ArrayList<Uri> uris = new ArrayList<>();
+ uris.add(uri);
+
+ Intent sendIntent = createSendUriIntentWithPreview(uris);
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
+
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test"));
+ waitForIdle();
+
+ onView(withId(com.android.intentresolver.R.id.scrollable_image_preview))
+ .check(matches(isDisplayed()));
+
+ onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp());
+ waitForIdle();
+
+ onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container))
+ .check(matches(isCompletelyDisplayed()));
+ onView(withId(com.android.intentresolver.R.id.headline))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.intentresolver.R.id.scrollable_image_preview))
+ .check(matches(not(isDisplayed())));
+ }
+
@Ignore // b/220067877
@Test
public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(0);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false;
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -2186,13 +2310,13 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(0);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_USER, false);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
@@ -2258,7 +2382,7 @@ public class UnbundledChooserActivityTest {
};
// Start activity
- final IChooserWrapper activity = (IChooserWrapper)
+ ChooserWrapperActivity activity =
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
@@ -2306,18 +2430,10 @@ public class UnbundledChooserActivityTest {
.perform(click());
waitForIdle();
- EventLog logger = activity.getEventLog();
- ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class);
- verify(logger, times(1)).logShareTargetSelected(
- eq(EventLog.SELECTION_TYPE_SERVICE),
- /* packageName= */ any(),
- /* positionPicked= */ anyInt(),
- /* directTargetAlsoRanked= */ anyInt(),
- /* numCallerProvided= */ anyInt(),
- /* directTargetHashed= */ any(),
- /* isPinned= */ anyBoolean(),
- /* successfullySelected= */ anyBoolean(),
- /* selectionCost= */ anyLong());
+ FakeEventLog eventLog = getEventLog(activity);
+ assertThat(eventLog.getShareTargetSelected()).hasSize(1);
+ FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0);
+ assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE);
}
@Test
@@ -2420,7 +2536,7 @@ public class UnbundledChooserActivityTest {
@Test @Ignore("b/222124533")
public void testSwitchProfileLogging() throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
@@ -2443,7 +2559,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
@@ -2476,13 +2592,7 @@ public class UnbundledChooserActivityTest {
chosen[0] = targetInfo.getResolveInfo();
return true;
};
- ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class);
- ResolveInfo ri = createFakeResolveInfo();
- when(
- ChooserActivityOverrideData
- .getInstance().packageManager
- .resolveActivity(any(Intent.class), any()))
- .thenReturn(ri);
+ mPackageManager = createFakePackageManager(createFakeResolveInfo());
waitForIdle();
IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
@@ -2495,7 +2605,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 1;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
@@ -2507,13 +2617,7 @@ public class UnbundledChooserActivityTest {
new Intent("action.fake2")
};
Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents);
- ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .packageManager
- .resolveActivity(any(Intent.class), any()))
- .thenReturn(createFakeResolveInfo());
+ mPackageManager = createFakePackageManager(createFakeResolveInfo());
waitForIdle();
IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
@@ -2525,7 +2629,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
@@ -2538,13 +2642,8 @@ public class UnbundledChooserActivityTest {
new Intent("action.fake2")
};
Intent chooserIntent = createChooserIntent(new Intent(), initialIntents);
- ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .packageManager
- .resolveActivity(any(Intent.class), any()))
- .thenReturn(createFakeResolveInfo());
+ mPackageManager = createFakePackageManager(createFakeResolveInfo());
+
mActivityRule.launchActivity(chooserIntent);
waitForIdle();
@@ -2559,7 +2658,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
List<ResolvedComponentInfo> workResolvedComponentInfos =
@@ -2570,13 +2669,8 @@ public class UnbundledChooserActivityTest {
new Intent("action.fake2")
};
Intent chooserIntent = createChooserIntent(new Intent(), initialIntents);
- ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .packageManager
- .resolveActivity(any(Intent.class), any()))
- .thenReturn(createFakeResolveInfo());
+ mPackageManager = createFakePackageManager(createFakeResolveInfo());
+
mActivityRule.launchActivity(chooserIntent);
waitForIdle();
@@ -2598,15 +2692,8 @@ public class UnbundledChooserActivityTest {
// Create caller target which is duplicate with one of app targets
Intent chooserIntent = createChooserIntent(createSendTextIntent(),
new Intent[] {new Intent("action.fake")});
- ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(0,
- UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .packageManager
- .resolveActivity(any(Intent.class), any()))
- .thenReturn(ri);
+ mPackageManager = createFakePackageManager(ResolverDataProvider.createResolveInfo(0,
+ UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE));
waitForIdle();
IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent);
@@ -2620,7 +2707,7 @@ public class UnbundledChooserActivityTest {
@Test
public void test_query_shortcut_loader_for_the_selected_tab() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos =
@@ -2653,12 +2740,12 @@ public class UnbundledChooserActivityTest {
@Test
public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() {
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- ChooserActivityOverrideData.getInstance().cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
setupResolverControllers(resolvedComponentInfos);
Intent sendIntent = createSendTextIntent();
@@ -2672,8 +2759,7 @@ public class UnbundledChooserActivityTest {
@Test
public void testClonedProfilePresent_personalTabUsesExpectedAdapter() {
- markWorkProfileUserAvailable();
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(
@@ -2690,6 +2776,16 @@ public class UnbundledChooserActivityTest {
assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE));
}
+ @Test
+ public void chooserDisabledWhileDeviceFrpLocked() {
+ mGlobalSettings.putBoolean(Settings.Global.SECURE_FRP_MODE, true);
+ Intent viewIntent = createSendTextIntent();
+ ChooserWrapperActivity activity = mActivityRule.launchActivity(
+ Intent.createChooser(viewIntent, "chooser test"));
+ waitForIdle();
+ assertTrue(activity.isFinishing());
+ }
+
private Intent createChooserIntent(Intent intent, Intent[] initialIntents) {
Intent chooserIntent = new Intent();
chooserIntent.setAction(Intent.ACTION_CHOOSER);
@@ -2714,7 +2810,7 @@ public class UnbundledChooserActivityTest {
final ChooserActivity activity = mActivityRule.launchActivity(
Intent.createChooser(new Intent("ACTION_FOO"), "foo"));
waitForIdle();
- assertThat(activity).isInstanceOf(com.android.intentresolver.ChooserWrapperActivity.class);
+ assertThat(activity).isInstanceOf(ChooserWrapperActivity.class);
}
private ResolveInfo createFakeResolveInfo() {
@@ -2899,35 +2995,6 @@ public class UnbundledChooserActivityTest {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
}
- private boolean launchActivityWithTimeout(Intent intent, long timeout)
- throws InterruptedException {
- final int initialState = 0;
- final int completedState = 1;
- final int timeoutState = 2;
- final AtomicInteger state = new AtomicInteger(initialState);
- final CountDownLatch cdl = new CountDownLatch(1);
-
- ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
- try {
- executor.execute(() -> {
- mActivityRule.launchActivity(intent);
- state.compareAndSet(initialState, completedState);
- cdl.countDown();
- });
- executor.schedule(
- () -> {
- state.compareAndSet(initialState, timeoutState);
- cdl.countDown();
- },
- timeout,
- TimeUnit.MILLISECONDS);
- cdl.await();
- return state.get() == completedState;
- } finally {
- executor.shutdownNow();
- }
- }
-
private Bitmap createBitmap() {
return createBitmap(200, 200);
}
@@ -2994,12 +3061,13 @@ public class UnbundledChooserActivityTest {
return shortcuts;
}
- private void markWorkProfileUserAvailable() {
- ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10);
- }
-
- private void markCloneProfileUserAvailable() {
- ChooserActivityOverrideData.getInstance().cloneProfileUserHandle = UserHandle.of(11);
+ private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) {
+ if (workAvailable) {
+ mFakeUserRepo.addUser(WORK_USER, /* available= */ true);
+ }
+ if (cloneAvailable) {
+ mFakeUserRepo.addUser(CLONE_USER, /* available= */ true);
+ }
}
private void setupResolverControllers(
@@ -3019,19 +3087,8 @@ public class UnbundledChooserActivityTest {
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- when(
- ChooserActivityOverrideData
- .getInstance()
- .workResolverListController
- .getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+ eq(PERSONAL_USER_HANDLE)))
+ .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
when(
ChooserActivityOverrideData
.getInstance()
@@ -3041,8 +3098,8 @@ public class UnbundledChooserActivityTest {
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.of(10))))
- .thenReturn(new ArrayList<>(workResolvedComponentInfos));
+ eq(WORK_PROFILE_USER_HANDLE)))
+ .thenReturn(new ArrayList<>(workResolvedComponentInfos));
}
private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) {
@@ -3105,8 +3162,4 @@ public class UnbundledChooserActivityTest {
};
return shortcutLoaders;
}
-
- private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) {
- return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap));
- }
}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java
index 92bccb7d..022ae2e1 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
+++ b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java
@@ -27,14 +27,14 @@ import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL;
+import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.WORK;
import static com.android.intentresolver.ChooserWrapperActivity.sOverrides;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL;
-import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK;
import static org.hamcrest.CoreMatchers.not;
import static org.mockito.ArgumentMatchers.eq;
@@ -44,11 +44,22 @@ import android.companion.DeviceFilter;
import android.content.Intent;
import android.os.UserHandle;
-import androidx.test.InstrumentationRegistry;
import androidx.test.espresso.NoMatchingViewException;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
-import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab;
+import com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab;
+import com.android.intentresolver.data.repository.FakeUserRepository;
+import com.android.intentresolver.data.repository.UserRepository;
+import com.android.intentresolver.data.repository.UserRepositoryModule;
+import com.android.intentresolver.inject.ApplicationUser;
+import com.android.intentresolver.inject.ProfileParent;
+import com.android.intentresolver.shared.model.User;
+
+import dagger.hilt.android.testing.BindValue;
+import dagger.hilt.android.testing.HiltAndroidRule;
+import dagger.hilt.android.testing.HiltAndroidTest;
+import dagger.hilt.android.testing.UninstallModules;
import junit.framework.AssertionFailedError;
@@ -66,20 +77,46 @@ import java.util.List;
@DeviceFilter.MediumType
@RunWith(Parameterized.class)
-public class UnbundledChooserActivityWorkProfileTest {
+@HiltAndroidTest
+@UninstallModules(UserRepositoryModule.class)
+public class ChooserActivityWorkProfileTest {
private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
.getInstrumentation().getTargetContext().getUser();
private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10);
- @Rule
+ @Rule(order = 0)
+ public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this);
+
+ @Rule(order = 1)
public ActivityTestRule<ChooserWrapperActivity> mActivityRule =
new ActivityTestRule<>(ChooserWrapperActivity.class, false,
false);
+
+ @BindValue
+ @ApplicationUser
+ public final UserHandle mApplicationUser;
+
+ @BindValue
+ @ProfileParent
+ public final UserHandle mProfileParent;
+
+ /** For setup of test state, a mutable reference of mUserRepository */
+ private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(
+ List.of(new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL)));
+
+ @BindValue
+ public final UserRepository mUserRepository;
+
private final TestCase mTestCase;
- public UnbundledChooserActivityWorkProfileTest(TestCase testCase) {
+ public ChooserActivityWorkProfileTest(TestCase testCase) {
mTestCase = testCase;
+ mApplicationUser = mTestCase.getMyUserHandle();
+ mProfileParent = PERSONAL_USER_HANDLE;
+ mUserRepository = new FakeUserRepository(List.of(
+ new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL),
+ new User(WORK_USER_HANDLE.getIdentifier(), User.Role.WORK)));
}
@Before
@@ -98,7 +135,6 @@ public class UnbundledChooserActivityWorkProfileTest {
public void testBlocker() {
setUpPersonalAndWorkComponentInfos();
sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents();
- sOverrides.tabOwnerUserHandleForLaunch = mTestCase.getMyUserHandle();
launchActivity(mTestCase.getIsSendAction());
switchToTab(mTestCase.getTab());
@@ -261,7 +297,6 @@ public class UnbundledChooserActivityWorkProfileTest {
}
private void setUpPersonalAndWorkComponentInfos() {
- markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3,
@@ -301,10 +336,6 @@ public class UnbundledChooserActivityWorkProfileTest {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
}
- private void markWorkProfileUserAvailable() {
- ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE;
- }
-
private void assertCantAccessWorkAppsBlockerDisplayed() {
onView(withText(R.string.resolver_cross_profile_blocked))
.check(matches(isDisplayed()));
diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java
index 8608cf72..6ff7af3f 100644
--- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
+++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java
@@ -19,12 +19,10 @@ package com.android.intentresolver;
import android.annotation.Nullable;
import android.app.prediction.AppPredictor;
import android.app.usage.UsageStatsManager;
-import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
-import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.database.Cursor;
@@ -32,17 +30,10 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.UserHandle;
-import androidx.lifecycle.ViewModelProvider;
-
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
-import com.android.intentresolver.flags.FeatureFlagRepository;
-import com.android.intentresolver.grid.ChooserGridAdapter;
-import com.android.intentresolver.icons.TargetDataLoader;
-import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
import com.android.intentresolver.shortcuts.ShortcutLoader;
-import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import java.util.List;
import java.util.function.Consumer;
@@ -51,20 +42,12 @@ import java.util.function.Consumer;
* Simple wrapper around chooser activity to be able to initiate it under test. For more
* information, see {@code com.android.internal.app.ChooserWrapperActivity}.
*/
-public class ChooserWrapperActivity
- extends com.android.intentresolver.ChooserActivity implements IChooserWrapper {
+public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper {
static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance();
private UsageStatsManager mUsm;
- // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at
- // onCreate and needs to see some non-negative value in the test.
@Override
- public int getLaunchedFromUid() {
- return 1234;
- }
-
- @Override
- public ChooserListAdapter createChooserListAdapter(
+ public final ChooserListAdapter createChooserListAdapter(
Context context,
List<Intent> payloadIntents,
Intent[] initialIntents,
@@ -73,28 +56,26 @@ public class ChooserWrapperActivity
ResolverListController resolverListController,
UserHandle userHandle,
Intent targetIntent,
- ChooserRequestParameters chooserRequest,
- int maxTargetsPerRow,
- TargetDataLoader targetDataLoader) {
- PackageManager packageManager =
- sOverrides.packageManager == null ? context.getPackageManager()
- : sOverrides.packageManager;
+ Intent referrerFillInIntent,
+ int maxTargetsPerRow) {
+
return new ChooserListAdapter(
context,
payloadIntents,
initialIntents,
rList,
filterLastUsed,
- createListController(userHandle),
+ resolverListController,
userHandle,
targetIntent,
+ referrerFillInIntent,
this,
- packageManager,
+ mPackageManager,
getEventLog(),
- chooserRequest,
maxTargetsPerRow,
userHandle,
- targetDataLoader);
+ mTargetDataLoader,
+ null);
}
@Override
@@ -104,17 +85,12 @@ public class ChooserWrapperActivity
@Override
public ChooserListAdapter getPersonalListAdapter() {
- return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0))
- .getListAdapter();
+ return mChooserMultiProfilePagerAdapter.getPersonalListAdapter();
}
@Override
public ChooserListAdapter getWorkListAdapter() {
- if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
- return null;
- }
- return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1))
- .getListAdapter();
+ return mChooserMultiProfilePagerAdapter.getWorkListAdapter();
}
@Override
@@ -123,16 +99,6 @@ public class ChooserWrapperActivity
}
@Override
- protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
- return new ChooserIntegratedDeviceComponents(
- /* editSharingComponent=*/ null,
- // An arbitrary pre-installed activity that handles this type of intent:
- /* nearbySharingComponent=*/ new ComponentName(
- "com.google.android.apps.messaging",
- ".ui.conversationlist.ShareIntentActivity"));
- }
-
- @Override
public UsageStatsManager getUsageStatsManager() {
if (mUsm == null) {
mUsm = getSystemService(UsageStatsManager.class);
@@ -157,14 +123,6 @@ public class ChooserWrapperActivity
}
@Override
- protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
- if (sOverrides.mWorkProfileAvailability != null) {
- return sOverrides.mWorkProfileAvailability;
- }
- return super.createWorkProfileAvailabilityManager();
- }
-
- @Override
public void safelyStartActivityInternal(TargetInfo cti, UserHandle user,
@Nullable Bundle options) {
if (sOverrides.onSafelyStartInternalCallback != null
@@ -175,7 +133,7 @@ public class ChooserWrapperActivity
}
@Override
- protected ChooserListController createListController(UserHandle userHandle) {
+ public final ChooserListController createListController(UserHandle userHandle) {
if (userHandle == UserHandle.SYSTEM) {
return sOverrides.resolverListController;
}
@@ -183,14 +141,6 @@ public class ChooserWrapperActivity
}
@Override
- public PackageManager getPackageManager() {
- if (sOverrides.createPackageManager != null) {
- return sOverrides.createPackageManager.apply(super.getPackageManager());
- }
- return super.getPackageManager();
- }
-
- @Override
public Resources getResources() {
if (sOverrides.resources != null) {
return sOverrides.resources;
@@ -199,18 +149,6 @@ public class ChooserWrapperActivity
}
@Override
- protected ViewModelProvider.Factory createPreviewViewModelFactory() {
- return TestContentPreviewViewModel.Companion.wrap(
- super.createPreviewViewModelFactory(),
- sOverrides.imageLoader);
- }
-
- @Override
- public EventLog getEventLog() {
- return sOverrides.mEventLog;
- }
-
- @Override
public Cursor queryResolver(ContentResolver resolver, Uri uri) {
if (sOverrides.resolverCursor != null) {
return sOverrides.resolverCursor;
@@ -224,48 +162,29 @@ public class ChooserWrapperActivity
}
@Override
- protected boolean isWorkProfile() {
- if (sOverrides.alternateProfileSetting != 0) {
- return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE;
- }
- return super.isWorkProfile();
- }
-
- @Override
- public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri,
- CharSequence pLabel, CharSequence pInfo, Intent replacementIntent,
- @Nullable TargetPresentationGetter resolveInfoPresentationGetter) {
+ public DisplayResolveInfo createTestDisplayResolveInfo(
+ Intent originalIntent,
+ ResolveInfo pri,
+ CharSequence pLabel,
+ CharSequence pInfo,
+ Intent replacementIntent) {
return DisplayResolveInfo.newDisplayResolveInfo(
originalIntent,
pri,
pLabel,
pInfo,
- replacementIntent,
- resolveInfoPresentationGetter);
- }
-
- @Override
- protected UserHandle getWorkProfileUserHandle() {
- return sOverrides.workProfileUserHandle;
+ replacementIntent);
}
@Override
public UserHandle getCurrentUserHandle() {
- return mMultiProfilePagerAdapter.getCurrentUserHandle();
- }
-
- @Override
- protected UserHandle getTabOwnerUserHandleForLaunch() {
- if (sOverrides.tabOwnerUserHandleForLaunch == null) {
- return super.getTabOwnerUserHandleForLaunch();
- }
- return sOverrides.tabOwnerUserHandleForLaunch;
+ return mChooserMultiProfilePagerAdapter.getCurrentUserHandle();
}
@Override
public Context createContextAsUser(UserHandle user, int flags) {
// return the current context as a work profile doesn't really exist in these tests
- return getApplicationContext();
+ return this;
}
@Override
@@ -283,12 +202,4 @@ public class ChooserWrapperActivity
return super.createShortcutLoader(
context, appPredictor, userHandle, targetIntentFilter, callback);
}
-
- @Override
- protected FeatureFlagRepository createFeatureFlagRepository() {
- if (sOverrides.featureFlagRepository != null) {
- return sOverrides.featureFlagRepository;
- }
- return super.createFeatureFlagRepository();
- }
}
diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/tests/activity/src/com/android/intentresolver/IChooserWrapper.java
index 3326d7f2..481cf3b2 100644
--- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java
+++ b/tests/activity/src/com/android/intentresolver/IChooserWrapper.java
@@ -16,14 +16,14 @@
package com.android.intentresolver;
-import android.annotation.Nullable;
import android.app.usage.UsageStatsManager;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.os.UserHandle;
+import androidx.annotation.Nullable;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
-import com.android.intentresolver.logging.EventLog;
import java.util.concurrent.Executor;
@@ -38,10 +38,12 @@ public interface IChooserWrapper {
ChooserListAdapter getWorkListAdapter();
boolean getIsSelected();
UsageStatsManager getUsageStatsManager();
- DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri,
- CharSequence pLabel, CharSequence pInfo, Intent replacementIntent,
- @Nullable TargetPresentationGetter resolveInfoPresentationGetter);
+ DisplayResolveInfo createTestDisplayResolveInfo(
+ Intent originalIntent,
+ ResolveInfo pri,
+ CharSequence pLabel,
+ CharSequence pInfo,
+ @Nullable Intent replacementIntent);
UserHandle getCurrentUserHandle();
- EventLog getEventLog();
Executor getMainExecutor();
}
diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java
index 7233fd3d..b44f4f91 100644
--- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java
@@ -25,6 +25,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isEnabled;
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.android.intentresolver.MatcherUtils.first;
import static com.android.intentresolver.ResolverWrapperActivity.sOverrides;
@@ -49,16 +50,27 @@ import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;
-import androidx.test.InstrumentationRegistry;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.NoMatchingViewException;
+import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;
+import com.android.intentresolver.data.repository.FakeUserRepository;
+import com.android.intentresolver.data.repository.UserRepository;
+import com.android.intentresolver.data.repository.UserRepositoryModule;
+import com.android.intentresolver.inject.ApplicationUser;
+import com.android.intentresolver.inject.ProfileParent;
+import com.android.intentresolver.shared.model.User;
import com.android.intentresolver.widget.ResolverDrawerLayout;
import com.google.android.collect.Lists;
+import dagger.hilt.android.testing.BindValue;
+import dagger.hilt.android.testing.HiltAndroidRule;
+import dagger.hilt.android.testing.HiltAndroidTest;
+import dagger.hilt.android.testing.UninstallModules;
+
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
@@ -73,18 +85,21 @@ import java.util.List;
* Resolver activity instrumentation tests
*/
@RunWith(AndroidJUnit4.class)
+@HiltAndroidTest
+@UninstallModules(UserRepositoryModule.class)
public class ResolverActivityTest {
- private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app
- .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser();
- protected Intent getConcreteIntentForLaunch(Intent clientIntent) {
- clientIntent.setClass(
- androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(),
- ResolverWrapperActivity.class);
- return clientIntent;
- }
+ private static final UserHandle PERSONAL_USER_HANDLE =
+ getInstrumentation().getTargetContext().getUser();
+ private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10);
+ private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11);
+ private static final User WORK_PROFILE_USER =
+ new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK);
+
+ @Rule(order = 0)
+ public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this);
- @Rule
+ @Rule(order = 1)
public ActivityTestRule<ResolverWrapperActivity> mActivityRule =
new ActivityTestRule<>(ResolverWrapperActivity.class, false, false);
@@ -92,14 +107,30 @@ public class ResolverActivityTest {
public void setup() {
// TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
// permissions we require (which we'll read from the manifest at runtime).
- androidx.test.platform.app.InstrumentationRegistry
- .getInstrumentation()
+ getInstrumentation()
.getUiAutomation()
.adoptShellPermissionIdentity();
sOverrides.reset();
}
+ @BindValue
+ @ApplicationUser
+ public final UserHandle mApplicationUser = PERSONAL_USER_HANDLE;
+
+ @BindValue
+ @ProfileParent
+ public final UserHandle mProfileParent = PERSONAL_USER_HANDLE;
+
+ /** For setup of test state, a mutable reference of mUserRepository */
+ private final FakeUserRepository mFakeUserRepo =
+ new FakeUserRepository(List.of(
+ new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL)
+ ));
+
+ @BindValue
+ public final UserRepository mUserRepository = mFakeUserRepo;
+
@Test
public void twoOptionsAndUserSelectsOne() throws InterruptedException {
Intent sendIntent = createSendImageIntent();
@@ -238,9 +269,9 @@ public class ResolverActivityTest {
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10,
PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0);
@@ -350,7 +381,7 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
Intent sendIntent = createSendImageIntent();
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
mActivityRule.launchActivity(sendIntent);
waitForIdle();
@@ -373,9 +404,9 @@ public class ResolverActivityTest {
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10,
PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos,
new ArrayList<>(workResolvedComponentInfos));
Intent sendIntent = createSendImageIntent();
@@ -390,15 +421,14 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_workTabUsesExpectedAdapter() {
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
- markWorkProfileUserAvailable();
final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
waitForIdle();
@@ -410,11 +440,11 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_personalTabUsesExpectedAdapter() {
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE);
- markWorkProfileUserAvailable();
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -428,12 +458,12 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -448,12 +478,13 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
+ createResolvedComponentsForTestWithOtherProfile(3,
+ /* userId */ WORK_PROFILE_USER_HANDLE.getIdentifier(),
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
ResolveInfo[] chosen = new ResolveInfo[1];
@@ -480,11 +511,11 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets()
throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -499,11 +530,11 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_headerIsVisibleInPersonalTab() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createOpenWebsiteIntent();
@@ -517,11 +548,11 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_switchTabs_headerStaysSame() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createOpenWebsiteIntent();
@@ -543,12 +574,12 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_noPersonalApps_canStartWorkApps()
throws InterruptedException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10,
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
ResolveInfo[] chosen = new ResolveInfo[1];
@@ -576,14 +607,13 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE);
sOverrides.hasCrossProfileIntents = false;
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -602,15 +632,14 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_workProfileDisabled_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10,
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
- sOverrides.isQuietModeEnabled = true;
+ createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE);
+ mFakeUserRepo.updateState(WORK_PROFILE_USER, false);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
@@ -628,11 +657,11 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
@@ -650,15 +679,15 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
- sOverrides.isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_PROFILE_USER, false);
sOverrides.hasCrossProfileIntents = false;
mActivityRule.launchActivity(sendIntent);
@@ -674,11 +703,11 @@ public class ResolverActivityTest {
@Test
public void testMiniResolver() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE);
// Personal profile only has a browser
personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true;
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
@@ -692,11 +721,11 @@ public class ResolverActivityTest {
@Test
public void testMiniResolver_noCurrentProfileTarget() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
@@ -720,15 +749,15 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
- sOverrides.isQuietModeEnabled = true;
+ mFakeUserRepo.updateState(WORK_PROFILE_USER, false);
mActivityRule.launchActivity(sendIntent);
waitForIdle();
@@ -743,14 +772,13 @@ public class ResolverActivityTest {
@Test
public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10,
PERSONAL_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(workProfileTargets,
- sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE);
sOverrides.hasCrossProfileIntents = false;
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -769,7 +797,7 @@ public class ResolverActivityTest {
@Test
public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException {
- markWorkProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false);
// In this case we prefer the other profile and don't display anything about the last
// chosen activity.
@@ -794,54 +822,53 @@ public class ResolverActivityTest {
@Test
public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() {
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
setupResolverControllers(resolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
waitForIdle();
- assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle()));
+ assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE));
assertThat(activity.getAdapter().getCount(), is(3));
}
@Test
public void testClonedProfilePresent_personalTabUsesExpectedAdapter() {
- markWorkProfileUserAvailable();
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4,
- sOverrides.workProfileUserHandle);
+ WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
waitForIdle();
- assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle()));
+ assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE));
assertThat(activity.getAdapter().getCount(), is(3));
}
@Test
public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception {
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true);
Intent sendIntent = createSendImageIntent();
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
2,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
setupResolverControllers(resolvedComponentInfos);
when(sOverrides.resolverListController.getLastChosen())
@@ -859,13 +886,13 @@ public class ResolverActivityTest {
@Test
public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception {
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true);
Intent sendIntent = createSendImageIntent();
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
setupResolverControllers(resolvedComponentInfos);
when(sOverrides.resolverListController.getLastChosen())
@@ -892,17 +919,16 @@ public class ResolverActivityTest {
@Test
public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser()
throws Exception {
- markWorkProfileUserAvailable();
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE);
sOverrides.hasCrossProfileIntents = false;
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -928,17 +954,16 @@ public class ResolverActivityTest {
@Test
public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser()
throws Exception {
- markWorkProfileUserAvailable();
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle);
+ createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
sendIntent.setType("TestType");
@@ -967,12 +992,12 @@ public class ResolverActivityTest {
public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers()
throws Exception {
// enable cloneProfile
- markCloneProfileUserAvailable();
+ markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true);
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsWithCloneProfileForTest(
3,
PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle);
+ CLONE_PROFILE_USER_HANDLE);
setupResolverControllers(resolvedComponentInfos);
Intent sendIntent = createSendImageIntent();
@@ -981,8 +1006,8 @@ public class ResolverActivityTest {
List<UserHandle> result = activity
.getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE);
- assertThat(result.containsAll(Lists.newArrayList(PERSONAL_USER_HANDLE,
- sOverrides.cloneProfileUserHandle)), is(true));
+ assertThat(result.containsAll(
+ Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true));
}
private Intent createSendImageIntent() {
@@ -1059,8 +1084,15 @@ public class ResolverActivityTest {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
}
- private void markWorkProfileUserAvailable() {
- ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10);
+ private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) {
+ if (workAvailable) {
+ mFakeUserRepo.addUser(
+ new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK), true);
+ }
+ if (cloneAvailable) {
+ mFakeUserRepo.addUser(
+ new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE), true);
+ }
}
private void setupResolverControllers(
@@ -1068,10 +1100,6 @@ public class ResolverActivityTest {
setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>());
}
- private void markCloneProfileUserAvailable() {
- ResolverWrapperActivity.sOverrides.cloneProfileUserHandle = UserHandle.of(11);
- }
-
private void setupResolverControllers(
List<ResolvedComponentInfo> personalResolvedComponentInfos,
List<ResolvedComponentInfo> workResolvedComponentInfos) {
@@ -1080,21 +1108,14 @@ public class ResolverActivityTest {
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- when(sOverrides.workResolverListController.getResolversForIntentAsUser(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class),
- eq(UserHandle.SYSTEM)))
+ eq(PERSONAL_USER_HANDLE)))
.thenReturn(new ArrayList<>(personalResolvedComponentInfos));
when(sOverrides.workResolverListController.getResolversForIntentAsUser(
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.anyBoolean(),
Mockito.isA(List.class),
- eq(UserHandle.of(10))))
+ eq(WORK_PROFILE_USER_HANDLE)))
.thenReturn(new ArrayList<>(workResolvedComponentInfos));
}
}
diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java
index 401ede26..0d317dc3 100644
--- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
+++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java
@@ -24,7 +24,6 @@ import static org.mockito.Mockito.when;
import android.annotation.Nullable;
import android.content.Context;
import android.content.Intent;
-import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
@@ -34,10 +33,11 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.test.espresso.idling.CountingIdlingResource;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.SelectableTargetInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.emptystate.CrossProfileIntentsChecker;
+import com.android.intentresolver.icons.LabelInfo;
import com.android.intentresolver.icons.TargetDataLoader;
import java.util.List;
@@ -53,17 +53,6 @@ public class ResolverWrapperActivity extends ResolverActivity {
private final CountingIdlingResource mLabelIdlingResource =
new CountingIdlingResource("LoadLabelTask");
- public ResolverWrapperActivity() {
- super(/* isIntentPicker= */ true);
- }
-
- // ResolverActivity inspects the launched-from UID at onCreate and needs to see some
- // non-negative value in the test.
- @Override
- public int getLaunchedFromUid() {
- return 1234;
- }
-
public CountingIdlingResource getLabelIdlingResource() {
return mLabelIdlingResource;
}
@@ -75,8 +64,7 @@ public class ResolverWrapperActivity extends ResolverActivity {
Intent[] initialIntents,
List<ResolveInfo> rList,
boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
+ UserHandle userHandle) {
return new ResolverListAdapter(
context,
payloadIntents,
@@ -88,7 +76,7 @@ public class ResolverWrapperActivity extends ResolverActivity {
payloadIntents.get(0), // TODO: extract upstream
this,
userHandle,
- new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource));
+ new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource));
}
@Override
@@ -99,27 +87,16 @@ public class ResolverWrapperActivity extends ResolverActivity {
return super.createCrossProfileIntentsChecker();
}
- @Override
- protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
- if (sOverrides.mWorkProfileAvailability != null) {
- return sOverrides.mWorkProfileAvailability;
- }
- return super.createWorkProfileAvailabilityManager();
- }
-
ResolverListAdapter getAdapter() {
return mMultiProfilePagerAdapter.getActiveListAdapter();
}
ResolverListAdapter getPersonalListAdapter() {
- return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0));
+ return mMultiProfilePagerAdapter.getPersonalListAdapter();
}
ResolverListAdapter getWorkListAdapter() {
- if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
- return null;
- }
- return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1));
+ return mMultiProfilePagerAdapter.getWorkListAdapter();
}
@Override
@@ -148,105 +125,42 @@ public class ResolverWrapperActivity extends ResolverActivity {
return sOverrides.workResolverListController;
}
- @Override
- public PackageManager getPackageManager() {
- if (sOverrides.createPackageManager != null) {
- return sOverrides.createPackageManager.apply(super.getPackageManager());
- }
- return super.getPackageManager();
- }
-
protected UserHandle getCurrentUserHandle() {
return mMultiProfilePagerAdapter.getCurrentUserHandle();
}
@Override
- protected UserHandle getWorkProfileUserHandle() {
- return sOverrides.workProfileUserHandle;
- }
-
- @Override
- protected UserHandle getCloneProfileUserHandle() {
- return sOverrides.cloneProfileUserHandle;
- }
-
- @Override
public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) {
super.startActivityAsUser(intent, options, user);
}
- @Override
- protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle
- userHandle) {
- return super.getResolverRankerServiceUserHandleListInternal(userHandle);
- }
-
/**
* We cannot directly mock the activity created since instrumentation creates it.
* <p>
* Instead, we use static instances of this object to modify behavior.
*/
- static class OverrideData {
+ public static class OverrideData {
@SuppressWarnings("Since15")
- public Function<PackageManager, PackageManager> createPackageManager;
public Function<Pair<TargetInfo, UserHandle>, Boolean> onSafelyStartInternalCallback;
public ResolverListController resolverListController;
public ResolverListController workResolverListController;
public Boolean isVoiceInteraction;
- public UserHandle workProfileUserHandle;
- public UserHandle cloneProfileUserHandle;
- public UserHandle tabOwnerUserHandleForLaunch;
- public Integer myUserId;
public boolean hasCrossProfileIntents;
- public boolean isQuietModeEnabled;
- public WorkProfileAvailabilityManager mWorkProfileAvailability;
public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
public void reset() {
onSafelyStartInternalCallback = null;
isVoiceInteraction = null;
- createPackageManager = null;
resolverListController = mock(ResolverListController.class);
workResolverListController = mock(ResolverListController.class);
- workProfileUserHandle = null;
- cloneProfileUserHandle = null;
- tabOwnerUserHandleForLaunch = null;
- myUserId = null;
hasCrossProfileIntents = true;
- isQuietModeEnabled = false;
-
- mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) {
- @Override
- public boolean isQuietModeEnabled() {
- return isQuietModeEnabled;
- }
-
- @Override
- public boolean isWorkProfileUserUnlocked() {
- return true;
- }
-
- @Override
- public void requestQuietModeEnabled(boolean enabled) {
- isQuietModeEnabled = enabled;
- }
-
- @Override
- public void markWorkProfileEnabledBroadcastReceived() {}
-
- @Override
- public boolean isWaitingToEnableWorkProfile() {
- return false;
- }
- };
-
mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class);
when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt()))
.thenAnswer(invocation -> hasCrossProfileIntents);
}
}
- private static class TargetDataLoaderWrapper extends TargetDataLoader {
+ private static class TargetDataLoaderWrapper implements TargetDataLoader {
private final TargetDataLoader mTargetDataLoader;
private final CountingIdlingResource mLabelIdlingResource;
@@ -257,25 +171,27 @@ public class ResolverWrapperActivity extends ResolverActivity {
}
@Override
- public void loadAppTargetIcon(
+ @Nullable
+ public Drawable getOrLoadAppTargetIcon(
@NonNull DisplayResolveInfo info,
@NonNull UserHandle userHandle,
@NonNull Consumer<Drawable> callback) {
- mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback);
+ return mTargetDataLoader.getOrLoadAppTargetIcon(info, userHandle, callback);
}
@Override
- public void loadDirectShareIcon(
+ @Nullable
+ public Drawable getOrLoadDirectShareIcon(
@NonNull SelectableTargetInfo info,
@NonNull UserHandle userHandle,
@NonNull Consumer<Drawable> callback) {
- mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback);
+ return mTargetDataLoader.getOrLoadDirectShareIcon(info, userHandle, callback);
}
@Override
public void loadLabel(
@NonNull DisplayResolveInfo info,
- @NonNull Consumer<CharSequence[]> callback) {
+ @NonNull Consumer<LabelInfo> callback) {
mLabelIdlingResource.increment();
mTargetDataLoader.loadLabel(
info,
@@ -285,10 +201,9 @@ public class ResolverWrapperActivity extends ResolverActivity {
});
}
- @NonNull
@Override
- public TargetPresentationGetter createPresentationGetter(@NonNull ResolveInfo info) {
- return mTargetDataLoader.createPresentationGetter(info);
+ public void getOrLoadLabel(@NonNull DisplayResolveInfo info) {
+ mTargetDataLoader.getOrLoadLabel(info);
}
}
}
diff --git a/java/tests/src/com/android/intentresolver/TestContentProvider.kt b/tests/activity/src/com/android/intentresolver/TestContentProvider.kt
index 426f9af2..426f9af2 100644
--- a/java/tests/src/com/android/intentresolver/TestContentProvider.kt
+++ b/tests/activity/src/com/android/intentresolver/TestContentProvider.kt
diff --git a/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt
new file mode 100644
index 00000000..90acaa60
--- /dev/null
+++ b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("RecyclerViewExt")
+
+package com.android.intentresolver.ext
+
+import androidx.recyclerview.widget.RecyclerView
+
+/** Ends active RecyclerView animations, if any */
+fun RecyclerView.endAnimations() {
+ if (isAnimating) {
+ itemAnimator?.endAnimations()
+ }
+}
diff --git a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt
new file mode 100644
index 00000000..d1dea7c3
--- /dev/null
+++ b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.intentresolver.logging
+
+import com.android.internal.logging.InstanceId
+import com.android.internal.logging.InstanceIdSequence
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import dagger.hilt.testing.TestInstallIn
+
+/** Binds a [FakeEventLog] as [EventLog] in tests. */
+@Module
+@TestInstallIn(components = [ActivityRetainedComponent::class], replaces = [EventLogModule::class])
+interface TestEventLogModule {
+
+ @Binds @ActivityRetainedScoped fun fakeEventLog(impl: FakeEventLog): EventLog
+
+ companion object {
+ @Provides
+ fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId()
+ }
+}
diff --git a/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt b/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt
new file mode 100644
index 00000000..9295f054
--- /dev/null
+++ b/tests/activity/src/com/android/intentresolver/platform/FakeSettingsModule.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.testing.TestInstallIn
+import javax.inject.Singleton
+
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [SettingsModule::class])
+object FakeSettingsModule {
+ @Provides @Singleton fun secureSettings(): SecureSettings = FakeSettings()
+
+ @Provides @Singleton fun systemSettings(): SystemSettings = FakeSettings()
+
+ @Provides @Singleton fun globalSettings(): GlobalSettings = FakeSettings()
+}
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
new file mode 100644
index 00000000..9109507a
--- /dev/null
+++ b/tests/integration/Android.bp
@@ -0,0 +1,44 @@
+//
+// 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 {
+ default_team: "trendy_team_capture_and_share",
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "IntentResolver-tests-integration",
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ ],
+
+ libs: [
+ "android.test.runner.stubs.system",
+ "android.test.base.stubs.system",
+ "framework",
+ ],
+ resource_dirs: ["res"],
+ test_config: "AndroidTest.xml",
+ static_libs: [
+ "androidx.test.runner",
+ "IntentResolver-core",
+ "IntentResolver-tests-shared",
+ "junit",
+ "truth",
+ ],
+ test_suites: ["general-tests"],
+}
diff --git a/tests/integration/AndroidManifest.xml b/tests/integration/AndroidManifest.xml
new file mode 100644
index 00000000..1a7b035d
--- /dev/null
+++ b/tests/integration/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?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.intentresolver.tests.integration" >
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.intentresolver.tests.integration">
+ </instrumentation>
+</manifest>
diff --git a/tests/integration/AndroidTest.xml b/tests/integration/AndroidTest.xml
new file mode 100644
index 00000000..4a2eee98
--- /dev/null
+++ b/tests/integration/AndroidTest.xml
@@ -0,0 +1,38 @@
+<?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.
+-->
+<configuration description="Run IntentResolver Tests.">
+ <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+ <option name="cleanup-apks" value="true" />
+ <option name="test-file-name" value="IntentResolver-tests-integration.apk" />
+ </target_preparer>
+
+ <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+ <option name="screen-always-on" value="on" />
+ </target_preparer>
+
+ <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+ <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+ <option name="run-command" value="wm dismiss-keyguard" />
+ </target_preparer>
+
+ <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer"/>
+
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+ <option name="package" value="com.android.intentresolver.tests.integration" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+ <option name="hidden-api-checks" value="false"/>
+ </test>
+</configuration>
diff --git a/tests/integration/res/values/strings.xml b/tests/integration/res/values/strings.xml
new file mode 100644
index 00000000..3115a7ae
--- /dev/null
+++ b/tests/integration/res/values/strings.xml
@@ -0,0 +1,18 @@
+<?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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+</resources>
diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt
index a4853fd8..af2836aa 100644
--- a/java/src/com/android/intentresolver/SecureSettings.kt
+++ b/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt
@@ -16,14 +16,10 @@
package com.android.intentresolver
-import android.content.ContentResolver
-import android.provider.Settings
+import org.junit.Test
-/**
- * A proxy class for secure settings, for easier testing.
- */
-open class SecureSettings {
- open fun getString(resolver: ContentResolver, name: String): String? {
- return Settings.Secure.getString(resolver, name)
- }
+class PlaceholderTest {
+
+ /** Allows this test target to function while tests are being developed. */
+ @Test fun placeHolder() {}
}
diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp
new file mode 100644
index 00000000..0f501c4f
--- /dev/null
+++ b/tests/shared/Android.bp
@@ -0,0 +1,39 @@
+//
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+ name: "IntentResolver-tests-shared",
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ ],
+ libs: [
+ "android.test.mock.stubs.system",
+ "framework",
+ ],
+ static_libs: [
+ "hamcrest",
+ "IntentResolver-core",
+ "kosmos",
+ "mockito-kotlin-nodeps",
+ "mockito-target-minus-junit4",
+ "truth",
+ ],
+}
diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt
index 849cfbab..eacefdc0 100644
--- a/java/tests/src/com/android/intentresolver/TestApplication.kt
+++ b/tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,12 +16,7 @@
package com.android.intentresolver
-import android.app.Application
-import android.content.Context
-import android.os.UserHandle
+import com.android.systemui.kosmos.Kosmos
+import kotlinx.coroutines.CoroutineDispatcher
-class TestApplication : Application() {
-
- // return the current context as a work profile doesn't really exist in these tests
- override fun createContextAsUser(user: UserHandle, flags: Int): Context = this
-} \ No newline at end of file
+var Kosmos.backgroundDispatcher: CoroutineDispatcher by Kosmos.Fixture()
diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
index bf87ed8a..76eb5e0d 100644
--- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt
+++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
@@ -18,16 +18,28 @@ package com.android.intentresolver
import android.graphics.Bitmap
import android.net.Uri
-import androidx.lifecycle.Lifecycle
+import android.util.Size
import com.android.intentresolver.contentpreview.ImageLoader
import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
-internal class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader {
- override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) {
+class FakeImageLoader(initialBitmaps: Map<Uri, Bitmap> = emptyMap()) : ImageLoader {
+ private val bitmaps = HashMap<Uri, Bitmap>().apply { putAll(initialBitmaps) }
+
+ override fun loadImage(
+ callerScope: CoroutineScope,
+ uri: Uri,
+ size: Size,
+ callback: Consumer<Bitmap?>,
+ ) {
callback.accept(bitmaps[uri])
}
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri]
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = bitmaps[uri]
+
+ override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) = Unit
- override fun prePopulate(uris: List<Uri>) = Unit
+ fun setBitmap(uri: Uri, bitmap: Bitmap) {
+ bitmaps[uri] = bitmap
+ }
}
diff --git a/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt b/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt
new file mode 100644
index 00000000..df3931c6
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.content.ContentResolver
+import android.content.pm.PackageManager
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.contentResolver by Kosmos.Fixture { org.mockito.kotlin.mock<ContentResolver> {} }
+var Kosmos.contentInterface by Kosmos.Fixture { contentResolver }
+var Kosmos.packageManager by Kosmos.Fixture { org.mockito.kotlin.mock<PackageManager> {} }
diff --git a/java/tests/src/com/android/intentresolver/MatcherUtils.java b/tests/shared/src/com/android/intentresolver/MatcherUtils.java
index 6168968b..97cc6984 100644
--- a/java/tests/src/com/android/intentresolver/MatcherUtils.java
+++ b/tests/shared/src/com/android/intentresolver/MatcherUtils.java
@@ -29,7 +29,7 @@ public class MatcherUtils {
/**
* Returns a {@link Matcher} which only matches the first occurrence of a set criteria.
*/
- static <T> Matcher<T> first(final Matcher<T> matcher) {
+ public static <T> Matcher<T> first(final Matcher<T> matcher) {
return new BaseMatcher<T>() {
boolean isFirstMatch = true;
diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt
new file mode 100644
index 00000000..755262ee
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2022 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.android.intentresolver
+
+import kotlin.DeprecationLevel.ERROR
+import kotlin.DeprecationLevel.WARNING
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatcher
+import org.mockito.ArgumentMatchers
+import org.mockito.MockSettings
+import org.mockito.Mockito
+import org.mockito.stubbing.Answer
+import org.mockito.stubbing.OngoingStubbing
+import org.mockito.stubbing.Stubber
+
+/*
+ * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects
+ * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not
+ * be null"). To fix this, we can use methods that modify the return type to be nullable. This
+ * causes Kotlin to skip the null checks. Cloned from
+ * frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt
+ */
+
+/**
+ * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when null is
+ * returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "eq", imports = ["org.mockito.kotlin.eq"]),
+ level = ERROR
+)
+inline fun <T> eq(obj: T): T = Mockito.eq<T>(obj) ?: obj
+
+/**
+ * Returns Mockito.same() as nullable type to avoid java.lang.IllegalStateException when null is
+ * returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "same(obj)", imports = ["org.mockito.kotlin.same"]),
+ level = ERROR
+)
+inline fun <T> same(obj: T): T = Mockito.same<T>(obj) ?: obj
+
+/**
+ * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when null is
+ * returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "any(type)", imports = ["org.mockito.kotlin.any"]),
+ level = WARNING
+)
+inline fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "any()", imports = ["org.mockito.kotlin.any"]),
+ level = WARNING
+)
+inline fun <reified T> any(): T = any(T::class.java)
+
+/**
+ * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when null is
+ * returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "argThat(matcher)", imports = ["org.mockito.kotlin.argThat"]),
+ level = WARNING
+)
+inline fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher)
+
+/**
+ * Kotlin type-inferred version of Mockito.nullable()
+ *
+ * @see org.mockito.kotlin.anyOrNull
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "anyOrNull()", imports = ["org.mockito.kotlin.anyOrNull"]),
+ level = ERROR
+)
+inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java)
+
+/**
+ * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException when
+ * null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ *
+ * @see org.mockito.kotlin.capture
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "capture(argumentCaptor)", imports = ["org.mockito.kotlin.capture"]),
+ level = ERROR
+)
+inline fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+
+/**
+ * Helper function for creating an argumentCaptor in kotlin.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ *
+ * @see org.mockito.kotlin.argumentCaptor
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "argumentCaptor()", imports = ["org.mockito.kotlin.argumentCaptor"]),
+ level = ERROR
+)
+inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
+ ArgumentCaptor.forClass(T::class.java)
+
+/**
+ * Helper function for creating new mocks, without the need to pass in a [Class] instance.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ *
+ * Updated kotlin-mockito usage:
+ * ```
+ * val value: Widget = mock<> {
+ * on { status } doReturn "OK"
+ * on { buttonPress } doNothing
+ * on { destroy } doAnswer error("Boom!")
+ * }
+ * ```
+ *
+ * __Deprecation note__
+ *
+ * Automatic replacement is not possible due to a change in lambda receiver type to KStubbing<T>
+ *
+ * @see org.mockito.kotlin.mock
+ * @see org.mockito.kotlin.KStubbing.on
+ */
+@Suppress("DeprecatedCallableAddReplaceWith")
+@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING)
+inline fun <reified T : Any> mock(
+ mockSettings: MockSettings = Mockito.withSettings(),
+ apply: T.() -> Unit = {}
+): T = Mockito.mock(T::class.java, mockSettings).apply(apply)
+
+/** Matches any array of type T. */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "anyArray()", imports = ["org.mockito.kotlin.anyArray"]),
+ level = ERROR
+)
+inline fun <reified T : Any?> anyArray(): Array<T> = Mockito.any(Array<T>::class.java) ?: arrayOf()
+
+/**
+ * Helper function for stubbing methods without the need to use backticks.
+ *
+ * Avoid. It is preferable to provide stubbing at creation time using the [mock] lambda argument.
+ *
+ * @see org.mockito.kotlin.whenever
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "whenever(methodCall)", imports = ["org.mockito.kotlin.whenever"]),
+ level = ERROR
+)
+inline fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall)
+
+/**
+ * Helper function for stubbing methods without the need to use backticks.
+ *
+ * Avoid. It is preferable to provide stubbing at creation time using the [mock] lambda argument.
+ *
+ * __Deprecation note__
+ *
+ * Replace with KStubber<T>.on within [org.mockito.kotlin.mock] { stubbing }
+ *
+ * @see org.mockito.kotlin.mock
+ * @see org.mockito.kotlin.KStubbing.on
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "whenever(mock)", imports = ["org.mockito.kotlin.whenever"]),
+ level = ERROR
+)
+inline fun <T> Stubber.whenever(mock: T): T = `when`(mock)
+
+/**
+ * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when
+ * kotlin tests are mocking kotlin objects and the methods take non-null parameters:
+ *
+ * java.lang.NullPointerException: capture() must not be null
+ */
+@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING)
+class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) {
+ private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz)
+ fun capture(): T = wrapped.capture()
+ val value: T
+ get() = wrapped.value
+ val allValues: List<T>
+ get() = wrapped.allValues
+}
+
+/**
+ * Helper function for creating an argumentCaptor in kotlin.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ *
+ * @see org.mockito.kotlin.argumentCaptor
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "argumentCaptor()", imports = ["org.mockito.kotlin.argumentCaptor"]),
+ level = WARNING
+)
+inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
+ KotlinArgumentCaptor(T::class.java)
+
+/**
+ * Helper function for creating and using a single-use ArgumentCaptor in kotlin.
+ *
+ * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured =
+ * captor.value
+ *
+ * becomes:
+ *
+ * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
+ *
+ * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
+ *
+ * @see org.mockito.kotlin.verify
+ */
+@Suppress("DeprecatedCallableAddReplaceWith")
+@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING)
+inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T =
+ kotlinArgumentCaptor<T>().apply { block() }.value
+
+/**
+ * Variant of [withArgCaptor] for capturing multiple arguments.
+ *
+ * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured:
+ * List<Foo> = captor.allValues
+ *
+ * becomes:
+ *
+ * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
+ *
+ * @see org.mockito.kotlin.verify
+ */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "capture()", imports = ["org.mockito.kotlin.capture"]),
+ level = WARNING
+)
+inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
+ kotlinArgumentCaptor<T>().apply { block() }.allValues
+
+/** @see org.mockito.kotlin.anyOrNull */
+@Deprecated(
+ "Replace with mockito-kotlin. See http://go/mockito-kotlin",
+ ReplaceWith(expression = "anyOrNull()", imports = ["org.mockito.kotlin.anyOrNull"]),
+ level = ERROR
+)
+inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true })
+
+/**
+ * @see org.mockito.kotlin.mock
+ * @see org.mockito.kotlin.doThrow
+ */
+@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = ERROR)
+val THROWS_EXCEPTION = Answer { error("Unstubbed behavior was accessed.") }
diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/tests/shared/src/com/android/intentresolver/ResolverDataProvider.java
index 1f8d9bee..db109941 100644
--- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
+++ b/tests/shared/src/com/android/intentresolver/ResolverDataProvider.java
@@ -29,6 +29,8 @@ import android.test.mock.MockContext;
import android.test.mock.MockPackageManager;
import android.test.mock.MockResources;
+import androidx.annotation.NonNull;
+
/**
* Utility class used by resolver tests to create mock data
*/
@@ -43,7 +45,7 @@ public class ResolverDataProvider {
createResolveInfo(i, UserHandle.USER_CURRENT));
}
- static ResolvedComponentInfo createResolvedComponentInfo(int i,
+ public static ResolvedComponentInfo createResolvedComponentInfo(int i,
UserHandle resolvedForUser) {
return new ResolvedComponentInfo(
createComponentName(i),
@@ -59,7 +61,7 @@ public class ResolverDataProvider {
createResolveInfo(componentName, UserHandle.USER_CURRENT));
}
- static ResolvedComponentInfo createResolvedComponentInfo(
+ public static ResolvedComponentInfo createResolvedComponentInfo(
ComponentName componentName, Intent intent, UserHandle resolvedForUser) {
return new ResolvedComponentInfo(
componentName,
@@ -74,8 +76,8 @@ public class ResolverDataProvider {
createResolveInfo(i, USER_SOMEONE_ELSE));
}
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
- UserHandle resolvedForUser) {
+ public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
+ UserHandle resolvedForUser) {
return new ResolvedComponentInfo(
createComponentName(i),
createResolverIntent(i),
@@ -89,7 +91,7 @@ public class ResolverDataProvider {
createResolveInfo(i, userId));
}
- static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
+ public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
int userId, UserHandle resolvedForUser) {
return new ResolvedComponentInfo(
createComponentName(i),
@@ -195,28 +197,31 @@ public class ResolverDataProvider {
@Override
public Resources getResources() {
return new MockResources() {
+ @NonNull
@Override
public String getString(int id) throws NotFoundException {
if (id == 1) return appLabel;
if (id == 2) return activityLabel;
if (id == 3) return resolveInfoLabel;
- return null;
+ throw new NotFoundException();
}
};
}
};
ApplicationInfo appInfo = new ApplicationInfo() {
+ @NonNull
@Override
- public CharSequence loadLabel(PackageManager pm) {
+ public CharSequence loadLabel(@NonNull PackageManager pm) {
return appLabel;
}
};
appInfo.labelRes = 1;
ActivityInfo activityInfo = new ActivityInfo() {
+ @NonNull
@Override
- public CharSequence loadLabel(PackageManager pm) {
+ public CharSequence loadLabel(@NonNull PackageManager pm) {
return activityLabel;
}
};
@@ -224,8 +229,9 @@ public class ResolverDataProvider {
activityInfo.applicationInfo = appInfo;
ResolveInfo resolveInfo = new ResolveInfo() {
+ @NonNull
@Override
- public CharSequence loadLabel(PackageManager pm) {
+ public CharSequence loadLabel(@NonNull PackageManager pm) {
return resolveInfoLabel;
}
};
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt
new file mode 100644
index 00000000..33969eb7
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview
+
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Size
+
+/** Fake implementation of [ThumbnailLoader] for use in testing. */
+class FakeThumbnailLoader(private val defaultSize: Size = Size(100, 100)) : ThumbnailLoader {
+
+ val fakeInvoke = mutableMapOf<Uri, suspend (Size) -> Bitmap?>()
+ val invokeCalls = mutableListOf<Uri>()
+ var unfinishedInvokeCount = 0
+
+ override suspend fun loadThumbnail(uri: Uri): Bitmap? = getBitmap(uri, defaultSize)
+
+ override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? = getBitmap(uri, size)
+
+ private suspend fun getBitmap(uri: Uri, size: Size): Bitmap? {
+ invokeCalls.add(uri)
+ unfinishedInvokeCount++
+ val result = fakeInvoke[uri]?.invoke(size)
+ unfinishedInvokeCount--
+ return result
+ }
+}
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt
new file mode 100644
index 00000000..4f979f54
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview
+
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.mimetypeClassifier: MimeTypeClassifier by Kosmos.Fixture { DefaultMimeTypeClassifier }
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt
new file mode 100644
index 00000000..bdee477d
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview
+
+import com.android.intentresolver.contentInterface
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.uriMetadataReader: UriMetadataReader by Kosmos.Fixture { uriMetadataReaderImpl }
+val Kosmos.uriMetadataReaderImpl
+ get() =
+ UriMetadataReaderImpl(
+ contentInterface,
+ mimetypeClassifier,
+ )
diff --git a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt
index f9fa2c6a..894ef163 100644
--- a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,18 +14,12 @@
* limitations under the License.
*/
-package com.android.intentresolver.flags
+package com.android.intentresolver.contentpreview.payloadtoggle.data.repository
-import com.android.systemui.flags.ReleasedFlag
-import com.android.systemui.flags.UnreleasedFlag
-import javax.annotation.concurrent.ThreadSafe
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
-@ThreadSafe
-internal class ReleaseFeatureFlagRepository(
- private val deviceConfig: DeviceConfigProxy,
-) : FeatureFlagRepository {
- override fun isEnabled(flag: UnreleasedFlag): Boolean = flag.default
-
- override fun isEnabled(flag: ReleasedFlag): Boolean =
- deviceConfig.isEnabled(flag) ?: flag.default
-}
+val Kosmos.activityResultRepository by Fixture { ActivityResultRepository() }
+val Kosmos.cursorPreviewsRepository by Fixture { CursorPreviewsRepository() }
+val Kosmos.pendingSelectionCallbackRepository by Fixture { PendingSelectionCallbackRepository() }
+val Kosmos.previewSelectionsRepository by Fixture { PreviewSelectionsRepository() }
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt
new file mode 100644
index 00000000..d53210bd
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.cursor
+
+import com.android.intentresolver.contentResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.inject.additionalContentUri
+import com.android.intentresolver.inject.chooserIntent
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.payloadToggleCursorResolver: CursorResolver<CursorRow?> by
+ Kosmos.Fixture { payloadToggleCursorResolverImpl }
+val Kosmos.payloadToggleCursorResolverImpl
+ get() =
+ PayloadToggleCursorResolver(
+ contentResolver,
+ additionalContentUri,
+ chooserIntent,
+ )
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt
new file mode 100644
index 00000000..1b4c0c8f
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import com.android.systemui.kosmos.Kosmos
+import org.mockito.kotlin.mock
+
+var Kosmos.pendingIntentSender by Kosmos.Fixture { mock<PendingIntentSender> {} }
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt
new file mode 100644
index 00000000..29e11a15
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.targetIntentModifier: TargetIntentModifier<PreviewModel> by Kosmos.Fixture()
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt
new file mode 100644
index 00000000..7cca414f
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import com.android.intentresolver.backgroundDispatcher
+import com.android.intentresolver.contentResolver
+import com.android.intentresolver.contentpreview.HeadlineGenerator
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.mimetypeClassifier
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback
+import com.android.intentresolver.contentpreview.uriMetadataReader
+import com.android.intentresolver.data.repository.chooserRequestRepository
+import com.android.intentresolver.inject.contentUris
+import com.android.intentresolver.logging.eventLog
+import com.android.intentresolver.packageManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+
+var Kosmos.focusedItemIndex: Int by Fixture { 0 }
+var Kosmos.pageSize: Int by Fixture { 16 }
+var Kosmos.maxLoadedPages: Int by Fixture { 3 }
+
+val Kosmos.chooserRequestInteractor
+ get() = ChooserRequestInteractor(chooserRequestRepository)
+
+val Kosmos.cursorPreviewsInteractor
+ get() =
+ CursorPreviewsInteractor(
+ interactor = setCursorPreviewsInteractor,
+ selectionInteractor = selectionInteractor,
+ focusedItemIdx = focusedItemIndex,
+ uriMetadataReader = uriMetadataReader,
+ pageSize = pageSize,
+ maxLoadedPages = maxLoadedPages,
+ )
+
+val Kosmos.customActionsInteractor
+ get() =
+ CustomActionsInteractor(
+ activityResultRepo = activityResultRepository,
+ bgDispatcher = backgroundDispatcher,
+ contentResolver = contentResolver,
+ eventLog = eventLog,
+ packageManager = packageManager,
+ chooserRequestInteractor = chooserRequestInteractor,
+ )
+
+val Kosmos.fetchPreviewsInteractor
+ get() =
+ FetchPreviewsInteractor(
+ setCursorPreviews = setCursorPreviewsInteractor,
+ selectionRepository = previewSelectionsRepository,
+ cursorInteractor = cursorPreviewsInteractor,
+ focusedItemIdx = focusedItemIndex,
+ selectedItems = contentUris,
+ uriMetadataReader = uriMetadataReader,
+ cursorResolver = payloadToggleCursorResolver,
+ )
+
+val Kosmos.processTargetIntentUpdatesInteractor
+ get() =
+ ProcessTargetIntentUpdatesInteractor(
+ selectionCallback = selectionChangeCallback,
+ repository = pendingSelectionCallbackRepository,
+ chooserRequestInteractor = updateChooserRequestInteractor,
+ )
+
+val Kosmos.selectablePreviewsInteractor
+ get() =
+ SelectablePreviewsInteractor(
+ previewsRepo = cursorPreviewsRepository,
+ selectionInteractor = selectionInteractor,
+ eventLog = eventLog,
+ )
+
+val Kosmos.selectionInteractor
+ get() =
+ SelectionInteractor(
+ selectionsRepo = previewSelectionsRepository,
+ targetIntentModifier = targetIntentModifier,
+ updateTargetIntentInteractor = updateTargetIntentInteractor,
+ mimeTypeClassifier = mimetypeClassifier,
+ )
+
+val Kosmos.setCursorPreviewsInteractor
+ get() = SetCursorPreviewsInteractor(previewsRepo = cursorPreviewsRepository)
+
+val Kosmos.updateChooserRequestInteractor
+ get() =
+ UpdateChooserRequestInteractor(
+ chooserRequestRepository,
+ pendingIntentSender,
+ )
+
+val Kosmos.updateTargetIntentInteractor
+ get() =
+ UpdateTargetIntentInteractor(
+ repository = pendingSelectionCallbackRepository,
+ chooserRequestInteractor = updateChooserRequestInteractor,
+ )
+
+var Kosmos.payloadToggleImageLoader: ImageLoader by Fixture()
+var Kosmos.headlineGenerator: HeadlineGenerator by Fixture()
diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt
new file mode 100644
index 00000000..b26b562e
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.update
+
+import com.android.intentresolver.contentInterface
+import com.android.intentresolver.inject.additionalContentUri
+import com.android.intentresolver.inject.chooserIntent
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.selectionChangeCallbackImpl by
+ Kosmos.Fixture {
+ SelectionChangeCallbackImpl(
+ additionalContentUri,
+ chooserIntent,
+ contentInterface,
+ )
+ }
+var Kosmos.selectionChangeCallback: SelectionChangeCallback by
+ Kosmos.Fixture { selectionChangeCallbackImpl }
diff --git a/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt
new file mode 100644
index 00000000..fb8fbd3f
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import com.android.intentresolver.shared.model.User
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+
+/** A simple repository which can be initialized from a list and updated. */
+class FakeUserRepository(userList: List<User>) : UserRepository {
+ internal data class UserState(val user: User, val available: Boolean)
+
+ private val userState = MutableStateFlow(userList.map { UserState(it, available = true) })
+
+ // Expose a List<User> from List<UserState>
+ override val users = userState.map { userList -> userList.map { it.user } }
+
+ fun addUser(user: User, available: Boolean) {
+ require(userState.value.none { it.user.id == user.id }) {
+ "A User with ${user.id} already exists!"
+ }
+ userState.update { it + UserState(user, available) }
+ }
+
+ fun removeUser(user: User) {
+ require(userState.value.any { it.user.id == user.id }) {
+ "A User with ${user.id} does not exist!"
+ }
+ userState.update { it.filterNot { state -> state.user.id == user.id } }
+ }
+
+ override val availability =
+ userState.map { userStateList -> userStateList.associate { it.user to it.available } }
+
+ fun updateState(user: User, available: Boolean) {
+ userState.update { userStateList ->
+ userStateList.map { userState ->
+ if (userState.user.id == user.id) {
+ UserState(user, available)
+ } else {
+ userState
+ }
+ }
+ }
+ }
+
+ override suspend fun requestState(user: User, available: Boolean) {
+ updateState(user, available)
+ }
+}
diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt
index 4ddb0447..0b2d3eb4 100644
--- a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
+++ b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -14,17 +14,16 @@
* limitations under the License.
*/
-package com.android.intentresolver.flags
+package com.android.intentresolver.data.repository
-import android.content.Context
-import android.os.Handler
-import android.os.Looper
-import com.android.systemui.flags.FlagManager
+import android.content.Intent
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
-class FeatureFlagRepositoryFactory {
- fun create(context: Context): FeatureFlagRepository =
- DebugFeatureFlagRepository(
- FlagManager(context, Handler(Looper.getMainLooper())),
- DeviceConfigProxy(),
- )
+var Kosmos.chooserRequestRepository by Fixture {
+ ChooserRequestRepository(
+ initialRequest = ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"),
+ initialActions = emptyList()
+ )
}
diff --git a/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt
new file mode 100644
index 00000000..0b9caa32
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.os.Parcel
+import android.os.Parcelable
+import java.lang.reflect.Field
+
+inline fun <reified T : Parcelable> T.toParcelAndBack(): T {
+ val creator: Parcelable.Creator<out T> = getCreator()
+ val parcel = Parcel.obtain()
+ writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+ return creator.createFromParcel(parcel)
+}
+
+inline fun <reified T : Parcelable> getCreator(): Parcelable.Creator<out T> {
+ return getCreator(T::class.java)
+}
+
+inline fun <reified T : Parcelable> getCreator(clazz: Class<out T>): Parcelable.Creator<out T> {
+ return try {
+ val field: Field = clazz.getDeclaredField("CREATOR")
+ @Suppress("UNCHECKED_CAST")
+ field.get(null) as Parcelable.Creator<T>
+ } catch (e: NoSuchFieldException) {
+ error("$clazz is a Parcelable without CREATOR")
+ } catch (e: IllegalAccessException) {
+ error("CREATOR in $clazz::class is not accessible")
+ }
+}
diff --git a/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt b/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt
new file mode 100644
index 00000000..9944163b
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.inject
+
+import android.content.Intent
+import android.net.Uri
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.contentUris: List<Uri> by Kosmos.Fixture { emptyList() }
+var Kosmos.additionalContentUri: Uri by
+ Kosmos.Fixture { Uri.fromParts("scheme", "ssp", "fragment") }
+var Kosmos.chooserIntent: Intent by Kosmos.Fixture { Intent() }
diff --git a/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt b/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt
new file mode 100644
index 00000000..5bf3ddee
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.logging
+
+import com.android.internal.logging.InstanceId
+import com.android.systemui.kosmos.Kosmos
+
+var Kosmos.eventLog by Kosmos.Fixture { fakeEventLog }
+var Kosmos.fakeEventLog by Kosmos.Fixture { FakeEventLog(InstanceId.fakeInstanceId(0)) }
diff --git a/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt
new file mode 100644
index 00000000..c2d13f1e
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt
@@ -0,0 +1,206 @@
+/*
+ * 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.intentresolver.logging
+
+import android.net.Uri
+import android.util.HashedStringCache
+import android.util.Log
+import com.android.internal.logging.InstanceId
+import javax.inject.Inject
+
+private const val TAG = "EventLog"
+private const val LOG = true
+
+/** A fake EventLog. */
+class FakeEventLog @Inject constructor(private val instanceId: InstanceId) : EventLog {
+
+ var chooserActivityShown: ChooserActivityShown? = null
+ var actionSelected: ActionSelected? = null
+ var customActionSelected: CustomActionSelected? = null
+ var actionShareWithPreview: ActionShareWithPreview? = null
+ val shareTargetSelected: MutableList<ShareTargetSelected> = mutableListOf()
+
+ private fun log(message: () -> Any?) {
+ if (LOG) {
+ Log.d(TAG, "[%04x] ".format(instanceId.id) + message())
+ }
+ }
+
+ override fun logChooserActivityShown(
+ isWorkProfile: Boolean,
+ targetMimeType: String?,
+ systemCost: Long
+ ) {
+ chooserActivityShown = ChooserActivityShown(isWorkProfile, targetMimeType, systemCost)
+ log { chooserActivityShown }
+ }
+
+ override fun logShareStarted(
+ packageName: String?,
+ mimeType: String?,
+ appProvidedDirect: Int,
+ appProvidedApp: Int,
+ isWorkprofile: Boolean,
+ previewType: Int,
+ intent: String?,
+ customActionCount: Int,
+ modifyShareActionProvided: Boolean
+ ) {
+ log {
+ ShareStarted(
+ packageName,
+ mimeType,
+ appProvidedDirect,
+ appProvidedApp,
+ isWorkprofile,
+ previewType,
+ intent,
+ customActionCount,
+ modifyShareActionProvided
+ )
+ }
+ }
+
+ override fun logCustomActionSelected(positionPicked: Int) {
+ customActionSelected = CustomActionSelected(positionPicked)
+ log { "logCustomActionSelected(positionPicked=$positionPicked)" }
+ }
+
+ override fun logShareTargetSelected(
+ targetType: Int,
+ packageName: String?,
+ positionPicked: Int,
+ directTargetAlsoRanked: Int,
+ numCallerProvided: Int,
+ directTargetHashed: HashedStringCache.HashResult?,
+ isPinned: Boolean,
+ successfullySelected: Boolean,
+ selectionCost: Long
+ ) {
+ shareTargetSelected.add(
+ ShareTargetSelected(
+ targetType,
+ packageName,
+ positionPicked,
+ directTargetAlsoRanked,
+ numCallerProvided,
+ directTargetHashed,
+ isPinned,
+ successfullySelected,
+ selectionCost
+ )
+ )
+ log { shareTargetSelected.last() }
+ shareTargetSelected.limitSize(10)
+ }
+
+ private fun MutableList<*>.limitSize(n: Int) {
+ while (size > n) {
+ removeFirst()
+ }
+ }
+
+ override fun logDirectShareTargetReceived(category: Int, latency: Int) {
+ log { "logDirectShareTargetReceived(category=$category, latency=$latency)" }
+ }
+
+ override fun logActionShareWithPreview(previewType: Int) {
+ actionShareWithPreview = ActionShareWithPreview(previewType)
+ log { actionShareWithPreview }
+ }
+
+ override fun logActionSelected(targetType: Int) {
+ actionSelected = ActionSelected(targetType)
+ log { actionSelected }
+ }
+
+ override fun logContentPreviewWarning(uri: Uri?) {
+ log { "logContentPreviewWarning(uri=$uri)" }
+ }
+
+ override fun logSharesheetTriggered() {
+ log { "logSharesheetTriggered()" }
+ }
+
+ override fun logSharesheetAppLoadComplete() {
+ log { "logSharesheetAppLoadComplete()" }
+ }
+
+ override fun logSharesheetDirectLoadComplete() {
+ log { "logSharesheetAppLoadComplete()" }
+ }
+
+ override fun logSharesheetDirectLoadTimeout() {
+ log { "logSharesheetDirectLoadTimeout()" }
+ }
+
+ override fun logSharesheetProfileChanged() {
+ log { "logSharesheetProfileChanged()" }
+ }
+
+ override fun logSharesheetExpansionChanged(isCollapsed: Boolean) {
+ log { "logSharesheetExpansionChanged(isCollapsed=$isCollapsed)" }
+ }
+
+ override fun logSharesheetAppShareRankingTimeout() {
+ log { "logSharesheetAppShareRankingTimeout()" }
+ }
+
+ override fun logSharesheetEmptyDirectShareRow() {
+ log { "logSharesheetEmptyDirectShareRow()" }
+ }
+
+ override fun logPayloadSelectionChanged() {
+ log { "logPayloadSelectionChanged" }
+ }
+
+ data class ActionSelected(val targetType: Int)
+
+ data class CustomActionSelected(val positionPicked: Int)
+
+ data class ActionShareWithPreview(val previewType: Int)
+
+ data class ChooserActivityShown(
+ val isWorkProfile: Boolean,
+ val targetMimeType: String?,
+ val systemCost: Long
+ )
+
+ data class ShareStarted(
+ val packageName: String?,
+ val mimeType: String?,
+ val appProvidedDirect: Int,
+ val appProvidedApp: Int,
+ val isWorkprofile: Boolean,
+ val previewType: Int,
+ val intent: String?,
+ val customActionCount: Int,
+ val modifyShareActionProvided: Boolean
+ )
+
+ data class ShareTargetSelected(
+ val targetType: Int,
+ val packageName: String?,
+ val positionPicked: Int,
+ val directTargetAlsoRanked: Int,
+ val numCallerProvided: Int,
+ val directTargetHashed: HashedStringCache.HashResult?,
+ val pinned: Boolean,
+ val successfullySelected: Boolean,
+ val selectionCost: Long
+ )
+}
diff --git a/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt b/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt
new file mode 100644
index 00000000..dcf8d23f
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt
@@ -0,0 +1,95 @@
+package com.android.intentresolver.logging
+/*
+ * 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.
+ */
+
+import com.android.internal.util.FrameworkStatsLog
+
+internal data class ShareSheetStarted(
+ val frameworkEventId: Int = FrameworkStatsLog.SHARESHEET_STARTED,
+ val appEventId: Int,
+ val packageName: String?,
+ val instanceId: Int,
+ val mimeType: String?,
+ val numAppProvidedDirectTargets: Int,
+ val numAppProvidedAppTargets: Int,
+ val isWorkProfile: Boolean,
+ val previewType: Int,
+ val intentType: Int,
+ val numCustomActions: Int,
+ val modifyShareActionProvided: Boolean
+)
+
+internal data class RankingSelected(
+ val frameworkEventId: Int = FrameworkStatsLog.RANKING_SELECTED,
+ val appEventId: Int,
+ val packageName: String?,
+ val instanceId: Int,
+ val positionPicked: Int,
+ val isPinned: Boolean
+)
+
+internal class FakeFrameworkStatsLogger : FrameworkStatsLogger {
+ var shareSheetStarted: ShareSheetStarted? = null
+ var rankingSelected: RankingSelected? = null
+ override fun write(
+ frameworkEventId: Int,
+ appEventId: Int,
+ packageName: String?,
+ instanceId: Int,
+ mimeType: String?,
+ numAppProvidedDirectTargets: Int,
+ numAppProvidedAppTargets: Int,
+ isWorkProfile: Boolean,
+ previewType: Int,
+ intentType: Int,
+ numCustomActions: Int,
+ modifyShareActionProvided: Boolean
+ ) {
+ shareSheetStarted =
+ ShareSheetStarted(
+ frameworkEventId,
+ appEventId,
+ packageName,
+ instanceId,
+ mimeType,
+ numAppProvidedDirectTargets,
+ numAppProvidedAppTargets,
+ isWorkProfile,
+ previewType,
+ intentType,
+ numCustomActions,
+ modifyShareActionProvided
+ )
+ }
+ override fun write(
+ frameworkEventId: Int,
+ appEventId: Int,
+ packageName: String?,
+ instanceId: Int,
+ positionPicked: Int,
+ isPinned: Boolean
+ ) {
+ rankingSelected =
+ RankingSelected(
+ frameworkEventId,
+ appEventId,
+ packageName,
+ instanceId,
+ positionPicked,
+ isPinned
+ )
+ }
+}
diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt
new file mode 100644
index 00000000..55cd7127
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/platform/FakeSettings.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+/**
+ * Creates a Settings instance with predefined values:
+ *
+ * val settings: SecureSettings = fakeSettings {
+ * putString("stringValue", "example")
+ * putInt("intValue", 42)
+ * }
+ */
+inline fun <reified T : SettingsProxy> fakeSettings(block: SettingsProxy.() -> Unit): T {
+ return FakeSettings(mutableMapOf()).apply(block) as T
+}
+
+/** A memory-only implementation of [SettingsProxy]. */
+class FakeSettings(
+ private val map: MutableMap<String, String>,
+) : GlobalSettings, SecureSettings, SystemSettings {
+ constructor() : this(mutableMapOf())
+
+ override fun getStringOrNull(name: String): String? = map[name]
+
+ override fun putString(name: String, value: String): Boolean {
+ map[name] = value
+ return true
+ }
+}
diff --git a/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt
new file mode 100644
index 00000000..32cb9062
--- /dev/null
+++ b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.Context
+import android.content.pm.UserInfo
+import android.content.pm.UserInfo.FLAG_FULL
+import android.content.pm.UserInfo.FLAG_INITIALIZED
+import android.content.pm.UserInfo.FLAG_PROFILE
+import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID
+import android.os.IUserManager
+import android.os.UserHandle
+import android.os.UserManager
+import androidx.annotation.NonNull
+import com.android.intentresolver.data.repository.AvailabilityChange
+import com.android.intentresolver.data.repository.ProfileAdded
+import com.android.intentresolver.data.repository.ProfileRemoved
+import com.android.intentresolver.data.repository.UserEvent
+import com.android.intentresolver.platform.FakeUserManager.State
+import kotlin.random.Random
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.consumeAsFlow
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+/**
+ * A stand-in for [UserManager] to support testing of data layer components which depend on it.
+ *
+ * This fake targets system applications which need to interact with any or all of the current
+ * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating
+ * non-profile (full) secondary users (switching active foreground user, adding or removing users)
+ * is not included.
+ *
+ * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This
+ * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or
+ * [getPrimaryUser][State.getPrimaryUser].
+ *
+ * To make state changes, use functions available from [FakeUserManager.state]:
+ * * [createProfile][State.createProfile]
+ * * [removeProfile][State.removeProfile]
+ * * [setQuietMode][State.setQuietMode]
+ *
+ * Any functionality not explicitly overridden here is guaranteed to throw an exception when
+ * accessed (access to the real system service is prevented).
+ */
+class FakeUserManager(val state: State = State()) :
+ UserManager(/* context = */ mockContext(), /* service = */ mockService()) {
+
+ enum class ProfileType {
+ WORK,
+ CLONE,
+ PRIVATE
+ }
+
+ override fun getProfileParent(userHandle: UserHandle): UserHandle? {
+ return state.getUserOrNull(userHandle)?.let { user ->
+ if (user.isProfile) {
+ state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle
+ } else {
+ null
+ }
+ }
+ }
+
+ override fun getUserInfo(userId: Int): UserInfo? {
+ return state.getUserOrNull(UserHandle.of(userId))
+ }
+
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun getEnabledProfiles(userId: Int): List<UserInfo> {
+ val user = state.users.single { it.id == userId }
+ return state.users.filter { other ->
+ user.id == other.id || user.profileGroupId == other.profileGroupId
+ }
+ }
+
+ override fun requestQuietModeEnabled(
+ enableQuietMode: Boolean,
+ @NonNull userHandle: UserHandle
+ ): Boolean {
+ state.setQuietMode(userHandle, enableQuietMode)
+ return true
+ }
+
+ override fun isQuietModeEnabled(userHandle: UserHandle): Boolean {
+ return state.getUser(userHandle).isQuietModeEnabled
+ }
+
+ override fun toString(): String {
+ return "FakeUserManager(state=$state)"
+ }
+
+ class State {
+ private val eventChannel = Channel<UserEvent>()
+ private val userInfoMap: MutableMap<UserHandle, UserInfo> = mutableMapOf()
+
+ /** The id of the primary/full/system user, which is automatically created. */
+ val primaryUserHandle: UserHandle
+
+ /**
+ * Retrieves the primary user. The value returned changes, but the values are immutable.
+ *
+ * Do not cache this value in tests, between operations.
+ */
+ fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle)
+
+ private var nextUserId: Int = 100 + Random.nextInt(0, 900)
+
+ /**
+ * A flow of [UserEvent] which emulates those normally generated from system broadcasts.
+ *
+ * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile].
+ */
+ val userEvents: Flow<UserEvent>
+
+ val users: List<UserInfo>
+ get() = userInfoMap.values.toList()
+
+ val userHandles: List<UserHandle>
+ get() = userInfoMap.keys.toList()
+
+ init {
+ primaryUserHandle = createPrimaryUser(allocateNextId())
+ userEvents = eventChannel.consumeAsFlow()
+ }
+
+ private fun allocateNextId() = nextUserId++
+
+ private fun createPrimaryUser(id: Int): UserHandle {
+ val userInfo =
+ UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM)
+ userInfoMap[userInfo.userHandle] = userInfo
+ return userInfo.userHandle
+ }
+
+ fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle]
+
+ fun getUser(handle: UserHandle): UserInfo =
+ requireNotNull(getUserOrNull(handle)) {
+ "Expected userInfoMap to contain an entry for $handle"
+ }
+
+ fun setQuietMode(user: UserHandle, quietMode: Boolean) {
+ userInfoMap[user]?.also {
+ it.flags =
+ if (quietMode) {
+ it.flags or UserInfo.FLAG_QUIET_MODE
+ } else {
+ it.flags and UserInfo.FLAG_QUIET_MODE.inv()
+ }
+ eventChannel.trySend(AvailabilityChange(user, quietMode))
+ }
+ }
+
+ fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle {
+ val parentUser = getUser(parent)
+ require(!parentUser.isProfile) { "Parent user cannot be a profile" }
+
+ // Ensure the parent user has a valid profileGroupId
+ if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) {
+ parentUser.profileGroupId = parentUser.id
+ }
+ val id = allocateNextId()
+ val userInfo =
+ UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply {
+ profileGroupId = parentUser.profileGroupId
+ }
+ userInfoMap[userInfo.userHandle] = userInfo
+ eventChannel.trySend(ProfileAdded(userInfo.userHandle))
+ return userInfo.userHandle
+ }
+
+ fun removeProfile(handle: UserHandle): Boolean {
+ return userInfoMap[handle]?.let { user ->
+ require(user.isProfile) { "Only profiles can be removed" }
+ userInfoMap.remove(user.userHandle)
+ eventChannel.trySend(ProfileRemoved(user.userHandle))
+ return true
+ }
+ ?: false
+ }
+
+ override fun toString() = buildString {
+ append("State(nextUserId=$nextUserId, userInfoMap=[")
+ userInfoMap.entries.forEach {
+ append("UserHandle[${it.key.identifier}] = ${it.value.debugString},")
+ }
+ append("])")
+ }
+ }
+}
+
+/** A safe mock of [Context] which throws on any unstubbed method call. */
+private fun mockContext(userHandle: UserHandle = UserHandle.SYSTEM): Context {
+ return mock<Context>(
+ defaultAnswer = {
+ error("Unstubbed behavior invoked! (${it.method}(${it.arguments.asList()})")
+ }
+ ) {
+ // Careful! Specify behaviors *first* to avoid throwing while stubbing!
+ doReturn(mock).whenever(mock).applicationContext
+ doReturn(userHandle).whenever(mock).user
+ doReturn(userHandle.identifier).whenever(mock).userId
+ }
+}
+
+private fun FakeUserManager.ProfileType.toUserType(): String {
+ return when (this) {
+ FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED
+ FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE
+ FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE
+ }
+}
+
+/** A safe mock of [IUserManager] which throws on any unstubbed method call. */
+fun mockService(): IUserManager {
+ return mock<IUserManager>(
+ defaultAnswer = {
+ error("Unstubbed behavior invoked! ${it.method}(${it.arguments.asList()}")
+ }
+ )
+}
+
+val UserInfo.debugString: String
+ get() =
+ "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " +
+ "type=$userType, flags=${UserInfo.flagsToString(flags)})"
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
new file mode 100644
index 00000000..a3b30a3a
--- /dev/null
+++ b/tests/unit/Android.bp
@@ -0,0 +1,66 @@
+//
+// 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 {
+ default_team: "trendy_team_capture_and_share",
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "IntentResolver-tests-unit",
+ manifest: "AndroidManifest.xml",
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ ],
+
+ libs: [
+ "android.test.runner.stubs.system",
+ "android.test.base.stubs.system",
+ "android.test.mock.stubs.system",
+ "framework",
+ "framework-res",
+ "flag-junit",
+ ],
+
+ resource_dirs: ["res"],
+ test_config: "AndroidTest.xml",
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.ext.truth",
+ "androidx.test.espresso.contrib",
+ "androidx.test.espresso.core",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "androidx.lifecycle_lifecycle-common-java8",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.lifecycle_lifecycle-runtime-testing",
+ "IntentResolver-core",
+ "IntentResolver-tests-shared",
+ "junit",
+ "kosmos",
+ "kotlinx_coroutines_test",
+ "mockito-target-minus-junit4",
+ "mockito-kotlin-nodeps",
+ "platform-compat-test-rules", // PlatformCompatChangeRule
+ "testables", // TestableContext/TestableResources
+ "truth",
+ "flag-junit",
+ "platform-test-annotations",
+ ],
+ test_suites: ["general-tests"],
+}
diff --git a/tests/unit/AndroidManifest.xml b/tests/unit/AndroidManifest.xml
new file mode 100644
index 00000000..80bc784a
--- /dev/null
+++ b/tests/unit/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.intentresolver.tests.unit">
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.intentresolver.tests.unit">
+ </instrumentation>
+</manifest>
diff --git a/java/tests/AndroidTest.xml b/tests/unit/AndroidTest.xml
index d1d77c10..2815c935 100644
--- a/java/tests/AndroidTest.xml
+++ b/tests/unit/AndroidTest.xml
@@ -15,14 +15,12 @@
-->
<configuration description="Run IntentResolver Tests.">
<target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
- <option name="test-file-name" value="IntentResolverUnitTests.apk" />
+ <option name="test-file-name" value="IntentResolver-tests-unit.apk" />
</target_preparer>
- <option name="test-suite-tag" value="apct" />
- <option name="test-tag" value="IntentResolverUnitTests" />
<test class="com.android.tradefed.testtype.AndroidJUnitTest" >
- <option name="package" value="com.android.intentresolver.tests" />
- <option name="runner" value="android.testing.TestableInstrumentation" />
+ <option name="package" value="com.android.intentresolver.tests.unit" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
<option name="hidden-api-checks" value="false"/>
</test>
</configuration>
diff --git a/tests/unit/res/values/strings.xml b/tests/unit/res/values/strings.xml
new file mode 100644
index 00000000..3115a7ae
--- /dev/null
+++ b/tests/unit/res/values/strings.xml
@@ -0,0 +1,18 @@
+<?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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+</resources>
diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt
index af6e5f16..8dfbdbdd 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt
+++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt
@@ -20,6 +20,7 @@ import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
+import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Resources
@@ -28,25 +29,29 @@ import android.service.chooser.ChooserAction
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.android.intentresolver.logging.EventLog
-import com.google.common.collect.ImmutableList
+import com.android.intentresolver.ui.ShareResultSender
+import com.android.intentresolver.ui.model.ShareAction
import com.google.common.truth.Truth.assertThat
+import java.util.Optional
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.mockito.Mockito
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
@RunWith(AndroidJUnit4::class)
class ChooserActionFactoryTest {
- private val context = InstrumentationRegistry.getInstrumentation().getContext()
+ private val context = InstrumentationRegistry.getInstrumentation().context
private val logger = mock<EventLog>()
private val actionLabel = "Action label"
- private val modifyShareLabel = "Modify share"
private val testAction = "com.android.intentresolver.testaction"
private val countdown = CountDownLatch(1)
private val testReceiver: BroadcastReceiver =
@@ -67,7 +72,7 @@ class ChooserActionFactoryTest {
@Before
fun setup() {
- context.registerReceiver(testReceiver, IntentFilter(testAction))
+ context.registerReceiver(testReceiver, IntentFilter(testAction), RECEIVER_EXPORTED)
}
@After
@@ -87,31 +92,10 @@ class ChooserActionFactoryTest {
// click it
customActions[0].onClicked.run()
- Mockito.verify(logger).logCustomActionSelected(eq(0))
+ verify(logger).logCustomActionSelected(eq(0))
assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
// Verify the pending intent has been called
- countdown.await(500, TimeUnit.MILLISECONDS)
- }
-
- @Test
- fun testNoModifyShareAction() {
- val factory = createFactory(includeModifyShare = false)
-
- assertThat(factory.modifyShareAction).isNull()
- }
-
- @Test
- fun testModifyShareAction() {
- val factory = createFactory(includeModifyShare = true)
-
- val action = factory.modifyShareAction ?: error("Modify share action should not be null")
- action.onClicked.run()
-
- Mockito.verify(logger)
- .logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE))
- assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
- // Verify the pending intent has been called
- countdown.await(500, TimeUnit.MILLISECONDS)
+ assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS))
}
@Test
@@ -121,21 +105,20 @@ class ChooserActionFactoryTest {
putExtra(Intent.EXTRA_TEXT, "Text to show")
}
- val chooserRequest =
- mock<ChooserRequestParameters> {
- whenever(this.targetIntent).thenReturn(targetIntent)
- whenever(chooserActions).thenReturn(ImmutableList.of())
- }
val testSubject =
ChooserActionFactory(
- context,
- chooserRequest,
- mock(),
- logger,
- {},
- { null },
- mock(),
- {},
+ /* context = */ context,
+ /* targetIntent = */ targetIntent,
+ /* referrerPackageName = */ null,
+ /* chooserActions = */ emptyList(),
+ /* imageEditor = */ Optional.empty(),
+ /* log = */ logger,
+ /* onUpdateSharedTextIsExcluded = */ {},
+ /* firstVisibleImageQuery = */ { null },
+ /* activityStarter = */ mock(),
+ /* shareResultSender = */ null,
+ /* finishCallback = */ {},
+ /* clipboardManager = */ mock(),
)
assertThat(testSubject.copyButtonRunnable).isNull()
}
@@ -143,51 +126,53 @@ class ChooserActionFactoryTest {
@Test
fun sendActionNoText_noCopyRunnable() {
val targetIntent = Intent(Intent.ACTION_SEND)
-
- val chooserRequest =
- mock<ChooserRequestParameters> {
- whenever(this.targetIntent).thenReturn(targetIntent)
- whenever(chooserActions).thenReturn(ImmutableList.of())
- }
val testSubject =
ChooserActionFactory(
- context,
- chooserRequest,
- mock(),
- logger,
- {},
- { null },
- mock(),
- {},
+ /* context = */ context,
+ /* targetIntent = */ targetIntent,
+ /* referrerPackageName = */ "com.example",
+ /* chooserActions = */ emptyList(),
+ /* imageEditor = */ Optional.empty(),
+ /* log = */ logger,
+ /* onUpdateSharedTextIsExcluded = */ {},
+ /* firstVisibleImageQuery = */ { null },
+ /* activityStarter = */ mock(),
+ /* shareResultSender = */ null,
+ /* finishCallback = */ {},
+ /* clipboardManager = */ mock(),
)
assertThat(testSubject.copyButtonRunnable).isNull()
}
@Test
- fun sendActionWithText_nonNullCopyRunnable() {
+ fun sendActionWithTextCopyRunnable() {
val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") }
-
- val chooserRequest =
- mock<ChooserRequestParameters> {
- whenever(this.targetIntent).thenReturn(targetIntent)
- whenever(chooserActions).thenReturn(ImmutableList.of())
- }
+ val resultSender = mock<ShareResultSender>()
val testSubject =
ChooserActionFactory(
- context,
- chooserRequest,
- mock(),
- logger,
- {},
- { null },
- mock(),
- {},
+ /* context = */ context,
+ /* targetIntent = */ targetIntent,
+ /* referrerPackageName = */ "com.example",
+ /* chooserActions = */ emptyList(),
+ /* imageEditor = */ Optional.empty(),
+ /* log = */ logger,
+ /* onUpdateSharedTextIsExcluded = */ {},
+ /* firstVisibleImageQuery = */ { null },
+ /* activityStarter = */ mock(),
+ /* shareResultSender = */ resultSender,
+ /* finishCallback = */ {},
+ /* clipboardManager = */ mock(),
)
assertThat(testSubject.copyButtonRunnable).isNotNull()
+
+ testSubject.copyButtonRunnable?.run()
+
+ verify(resultSender) { 1 * { onActionSelected(ShareAction.SYSTEM_COPY) } }
}
- private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory {
- val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction), 0)
+ private fun createFactory(): ChooserActionFactory {
+ val testPendingIntent =
+ PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE)
val targetIntent = Intent()
val action =
ChooserAction.Builder(
@@ -196,30 +181,19 @@ class ChooserActionFactoryTest {
testPendingIntent
)
.build()
- val chooserRequest = mock<ChooserRequestParameters>()
- whenever(chooserRequest.targetIntent).thenReturn(targetIntent)
- whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action))
-
- if (includeModifyShare) {
- val modifyShare =
- ChooserAction.Builder(
- Icon.createWithResource("", Resources.ID_NULL),
- modifyShareLabel,
- testPendingIntent
- )
- .build()
- whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare)
- }
-
return ChooserActionFactory(
- context,
- chooserRequest,
- mock(),
- logger,
- {},
- { null },
- mock(),
- resultConsumer
+ /* context = */ context,
+ /* targetIntent = */ targetIntent,
+ /* referrerPackageName = */ "com.example",
+ /* chooserActions = */ listOf(action),
+ /* imageEditor = */ Optional.empty(),
+ /* log = */ logger,
+ /* onUpdateSharedTextIsExcluded = */ {},
+ /* firstVisibleImageQuery = */ { null },
+ /* activityStarter = */ mock(),
+ /* shareResultSender = */ null,
+ /* finishCallback = */ resultConsumer,
+ /* clipboardManager = */ mock(),
)
}
}
diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt
new file mode 100644
index 00000000..bbef6c0c
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.ComponentInfoFlags
+import android.os.UserHandle
+import android.os.UserManager
+import android.view.LayoutInflater
+import com.android.intentresolver.ResolverDataProvider.createActivityInfo
+import com.android.intentresolver.ResolverDataProvider.createResolvedComponentInfo
+import com.android.intentresolver.icons.TargetDataLoader
+import com.android.intentresolver.logging.FakeEventLog
+import com.android.intentresolver.util.TestExecutor
+import com.android.internal.logging.InstanceId
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+class ChooserListAdapterDataTest {
+ private val layoutInflater = mock<LayoutInflater>()
+ private val packageManager = mock<PackageManager>()
+ private val userManager = mock<UserManager> { on { isManagedProfile } doReturn false }
+ private val resources =
+ mock<android.content.res.Resources> {
+ on { getInteger(R.integer.config_maxShortcutTargetsPerApp) } doReturn 2
+ }
+ private val context =
+ mock<Context> {
+ on { getSystemService(Context.LAYOUT_INFLATER_SERVICE) } doReturn layoutInflater
+ on { getSystemService(Context.USER_SERVICE) } doReturn userManager
+ on { packageManager } doReturn this@ChooserListAdapterDataTest.packageManager
+ on { resources } doReturn this@ChooserListAdapterDataTest.resources
+ }
+ private val targetIntent = Intent(Intent.ACTION_SEND)
+ private val payloadIntents = listOf(targetIntent)
+ private val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ }
+ private val resolverListCommunicator = FakeResolverListCommunicator()
+ private val userHandle = UserHandle.of(UserHandle.USER_CURRENT)
+ private val targetDataLoader = mock<TargetDataLoader>()
+ private val backgroundExecutor = TestExecutor()
+ private val immediateExecutor = TestExecutor(immediate = true)
+ private val referrerFillInIntent =
+ Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package")
+
+ @Test
+ fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() {
+ val resolvedTargets =
+ listOf(
+ createResolvedComponentInfo(1),
+ createResolvedComponentInfo(2),
+ )
+ val targetIntent = Intent(Intent.ACTION_SEND)
+ whenever(
+ resolverListController.getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ )
+ .thenReturn(ArrayList(resolvedTargets))
+ val initialActivityInfo = createActivityInfo(3)
+ val initialIntents =
+ arrayOf(
+ Intent(Intent.ACTION_SEND).apply { component = initialActivityInfo.componentName }
+ )
+ whenever(
+ packageManager.getActivityInfo(
+ eq(initialActivityInfo.componentName),
+ any<ComponentInfoFlags>()
+ )
+ )
+ .thenReturn(initialActivityInfo)
+ val testSubject =
+ ChooserListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ false,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ referrerFillInIntent,
+ resolverListCommunicator,
+ packageManager,
+ FakeEventLog(InstanceId.fakeInstanceId(1)),
+ /*maxRankedTargets=*/ 2,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ null,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ assertThat(testSubject.displayResolveInfoCount).isEqualTo(0)
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.displayResolveInfoCount).isEqualTo(resolvedTargets.size)
+ }
+
+ @Test
+ fun test_twoTargetsWithOverlappingInitialIntent_oneTargetsInResolverAdapter() {
+ val resolvedTargets =
+ listOf(
+ createResolvedComponentInfo(1),
+ createResolvedComponentInfo(2),
+ )
+ val targetIntent = Intent(Intent.ACTION_SEND)
+ whenever(
+ resolverListController.getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ )
+ .thenReturn(ArrayList(resolvedTargets))
+ val activityInfo = resolvedTargets[1].getResolveInfoAt(0).activityInfo
+ val initialIntents =
+ arrayOf(Intent(Intent.ACTION_SEND).apply { component = activityInfo.componentName })
+ whenever(
+ packageManager.getActivityInfo(
+ eq(activityInfo.componentName),
+ any<ComponentInfoFlags>()
+ )
+ )
+ .thenReturn(activityInfo)
+ val testSubject =
+ ChooserListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ false,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ referrerFillInIntent,
+ resolverListCommunicator,
+ packageManager,
+ FakeEventLog(InstanceId.fakeInstanceId(1)),
+ /*maxRankedTargets=*/ 2,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ null,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ assertThat(testSubject.displayResolveInfoCount).isEqualTo(0)
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.displayResolveInfoCount).isEqualTo(resolvedTargets.size - 1)
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt
index c8cb4b9b..cdc84ba8 100644
--- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt
+++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt
@@ -20,6 +20,7 @@ import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.PackageManager.ResolveInfoFlags
+import android.content.pm.ShortcutInfo
import android.os.UserHandle
import android.view.View
import android.widget.FrameLayout
@@ -31,13 +32,18 @@ import com.android.intentresolver.chooser.DisplayResolveInfo
import com.android.intentresolver.chooser.SelectableTargetInfo
import com.android.intentresolver.chooser.TargetInfo
import com.android.intentresolver.icons.TargetDataLoader
-import com.android.intentresolver.logging.EventLog
+import com.android.intentresolver.logging.EventLogImpl
+import com.android.intentresolver.widget.BadgeTextView
import com.android.internal.R
+import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
@RunWith(AndroidJUnit4::class)
class ChooserListAdapterTest {
@@ -46,12 +52,15 @@ class ChooserListAdapterTest {
private val packageManager =
mock<PackageManager> {
- whenever(resolveActivity(any(), any<ResolveInfoFlags>())).thenReturn(mock())
+ on { resolveActivity(any(), any<ResolveInfoFlags>()) } doReturn (mock())
}
private val context = InstrumentationRegistry.getInstrumentation().context
private val resolverListController = mock<ResolverListController>()
- private val mEventLog = mock<EventLog>()
+ private val appLabel = "App"
+ private val targetLabel = "Target"
+ private val mEventLog = mock<EventLogImpl>()
private val mTargetDataLoader = mock<TargetDataLoader>()
+ private val mPackageChangeCallback = mock<ChooserListAdapter.PackageChangeCallback>()
private val testSubject by lazy {
ChooserListAdapter(
@@ -63,13 +72,14 @@ class ChooserListAdapterTest {
resolverListController,
userHandle,
Intent(),
+ Intent(),
mock(),
packageManager,
mEventLog,
- mock(),
0,
null,
- mTargetDataLoader
+ mTargetDataLoader,
+ mPackageChangeCallback,
)
}
@@ -89,7 +99,7 @@ class ChooserListAdapterTest {
val targetInfo = createSelectableTargetInfo()
testSubject.onBindView(view, targetInfo, 0)
- verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any())
+ verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any())
}
@Test
@@ -105,7 +115,7 @@ class ChooserListAdapterTest {
testSubject.onBindView(view, targetInfo, 0)
- verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any())
+ verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any())
}
@Test
@@ -119,8 +129,7 @@ class ChooserListAdapterTest {
ResolverDataProvider.createResolveInfo(2, 0, userHandle),
null,
"extended info",
- Intent(),
- /* resolveInfoPresentationGetter= */ null
+ Intent()
)
testSubject.onBindView(view, targetInfo, 0)
@@ -129,36 +138,89 @@ class ChooserListAdapterTest {
testSubject.onBindView(view, targetInfo, 0)
- verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any())
+ verify(mTargetDataLoader, times(1)).getOrLoadAppTargetIcon(any(), any(), any())
}
- private fun createSelectableTargetInfo(): TargetInfo =
- SelectableTargetInfo.newSelectableTargetInfo(
- /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo(
- Intent(),
- ResolverDataProvider.createResolveInfo(2, 0, userHandle),
- "label",
- "extended info",
- Intent(),
- /* resolveInfoPresentationGetter= */ null
- ),
+ @Test
+ fun onBindView_contentDescription() {
+ val view = createView()
+ val viewHolder = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolder
+ val targetInfo = createSelectableTargetInfo()
+ testSubject.onBindView(view, targetInfo, 0)
+
+ assertThat(view.contentDescription).isEqualTo("$targetLabel $appLabel")
+ }
+
+ @Test
+ fun onBindView_contentDescriptionPinned() {
+ val view = createView()
+ val viewHolder = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolder
+ val targetInfo = createSelectableTargetInfo(true)
+ testSubject.onBindView(view, targetInfo, 0)
+
+ assertThat(view.contentDescription).isEqualTo("$targetLabel $appLabel. Pinned")
+ }
+
+ @Test
+ fun onBindView_displayInfoContentDescriptionPinned() {
+ val view = createView()
+ val viewHolder = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolder
+ val targetInfo = createDisplayResolveInfo(isPinned = true)
+ testSubject.onBindView(view, targetInfo, 0)
+
+ assertThat(view.contentDescription).isEqualTo("$appLabel. Pinned")
+ }
+
+ @Test
+ fun handlePackagesChanged_invokesCallback() {
+ testSubject.handlePackagesChanged()
+ verify(mPackageChangeCallback, times(1)).beforeHandlingPackagesChanged()
+ }
+
+ private fun createSelectableTargetInfo(isPinned: Boolean = false): TargetInfo {
+ val shortcutInfo =
+ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1).apply {
+ if (isPinned) {
+ addFlags(ShortcutInfo.FLAG_PINNED)
+ }
+ }
+ return SelectableTargetInfo.newSelectableTargetInfo(
+ /* sourceInfo = */ createDisplayResolveInfo(isPinned),
/* backupResolveInfo = */ mock(),
/* resolvedIntent = */ Intent(),
/* chooserTarget = */ createChooserTarget(
- "Target",
+ targetLabel,
0.5f,
ComponentName("pkg", "Class"),
"id-1"
),
/* modifiedScore = */ 1f,
- /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1),
+ shortcutInfo,
/* appTarget */ null,
/* referrerFillInIntent = */ Intent()
)
+ }
+
+ private fun createDisplayResolveInfo(isPinned: Boolean = false): DisplayResolveInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(2, 0, userHandle),
+ appLabel,
+ "extended info",
+ Intent(),
+ )
+ .apply {
+ if (isPinned) {
+ setPinned(true)
+ }
+ }
private fun createView(): View {
val view = FrameLayout(context)
- TextView(context).apply {
+ BadgeTextView(context).apply {
id = R.id.text1
view.addView(this)
}
diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
index bd355c86..f5210f71 100644
--- a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
+++ b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
@@ -29,16 +29,19 @@ import androidx.lifecycle.Observer
import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion
+import com.android.intentresolver.ChooserRefinementManager.RefinementType
import com.android.intentresolver.chooser.ImmutableTargetInfo
-import com.android.intentresolver.chooser.TargetInfo
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.mockito.ArgumentCaptor
-import org.mockito.Mockito
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
@RunWith(AndroidJUnit4::class)
@UiThreadTest
@@ -55,15 +58,15 @@ class ChooserRefinementManagerTest {
object : Observer<RefinementCompletion> {
val failureCountDown = CountDownLatch(1)
val successCountDown = CountDownLatch(1)
- var latestTargetInfo: TargetInfo? = null
+ var latestRefinedIntent: Intent? = null
override fun onChanged(completion: RefinementCompletion) {
if (completion.consume()) {
- val targetInfo = completion.targetInfo
- if (targetInfo == null) {
+ val refinedIntent = completion.refinedIntent
+ if (refinedIntent == null) {
failureCountDown.countDown()
} else {
- latestTargetInfo = targetInfo
+ latestRefinedIntent = refinedIntent
successCountDown.countDown()
}
}
@@ -90,33 +93,31 @@ class ChooserRefinementManagerTest {
exampleTargetInfo,
intentSender,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isTrue()
- val intentCaptor = ArgumentCaptor.forClass(Intent::class.java)
- Mockito.verify(intentSender)
- .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
+ val intentCaptor = argumentCaptor<Intent>()
+ verify(intentSender).sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
- val intent = intentCaptor.value
- assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
+ val intent = intentCaptor.firstValue
+ assertThat(intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
.isEqualTo(exampleSourceIntents[0])
val alternates =
- intent?.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java)
+ intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java)
assertThat(alternates?.size).isEqualTo(1)
assertThat(alternates?.get(0)).isEqualTo(exampleSourceIntents[1])
// Complete the refinement
val receiver =
- intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java)
+ intent.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java)
val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) }
receiver?.send(Activity.RESULT_OK, bundle)
assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue()
- assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action)
- .isEqualTo(Intent.ACTION_VIEW)
+ assertThat(completionObserver.latestRefinedIntent?.action).isEqualTo(Intent.ACTION_VIEW)
}
@Test
@@ -126,16 +127,15 @@ class ChooserRefinementManagerTest {
exampleTargetInfo,
intentSender,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isTrue()
- val intentCaptor = ArgumentCaptor.forClass(Intent::class.java)
- Mockito.verify(intentSender)
- .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
+ val intentCaptor = argumentCaptor<Intent>()
+ verify(intentSender).sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
- val intent = intentCaptor.value
+ val intent = intentCaptor.firstValue
// Complete the refinement
val receiver =
@@ -153,7 +153,7 @@ class ChooserRefinementManagerTest {
ImmutableTargetInfo.newBuilder().build(),
intentSender,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isFalse()
@@ -172,7 +172,7 @@ class ChooserRefinementManagerTest {
targetInfo,
intentSender,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isFalse()
@@ -185,7 +185,7 @@ class ChooserRefinementManagerTest {
exampleTargetInfo,
/* IntentSender */ null,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isFalse()
@@ -198,7 +198,7 @@ class ChooserRefinementManagerTest {
exampleTargetInfo,
intentSender,
application,
- FakeHandler(Looper.myLooper())
+ FakeHandler(checkNotNull(Looper.myLooper()))
)
)
.isTrue()
@@ -216,7 +216,7 @@ class ChooserRefinementManagerTest {
exampleTargetInfo,
intentSender,
application,
- FakeHandler(Looper.myLooper()!!)
+ FakeHandler(checkNotNull(Looper.myLooper())!!)
)
)
.isTrue()
@@ -231,10 +231,11 @@ class ChooserRefinementManagerTest {
@Test
fun testRefinementCompletion() {
- val refinementCompletion = RefinementCompletion(exampleTargetInfo)
- assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
+ val refinementCompletion =
+ RefinementCompletion(RefinementType.TARGET_INFO, exampleTargetInfo, null)
+ assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo)
assertThat(refinementCompletion.consume()).isTrue()
- assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
+ assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo)
// can only consume once.
assertThat(refinementCompletion.consume()).isFalse()
diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
index c7d20000..2b7d6ff9 100644
--- a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
+++ b/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
@@ -31,10 +31,11 @@ import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.never
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
private const val TIMEOUT_MS = 200
@@ -48,18 +49,18 @@ class EnterTransitionAnimationDelegateTest {
private val transitionTargetView =
mock<View> {
// avoid the request-layout path in the delegate
- whenever(isInLayout).thenReturn(true)
+ on { isInLayout } doReturn true
}
private val windowMock = mock<Window>()
private val resourcesMock =
- mock<Resources> { whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) }
+ mock<Resources> { on { getInteger(any<Int>()) } doReturn TIMEOUT_MS }
private val activity =
mock<ComponentActivity> {
- whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle)
- whenever(resources).thenReturn(resourcesMock)
- whenever(isActivityTransitionRunning).thenReturn(true)
- whenever(window).thenReturn(windowMock)
+ on { lifecycle } doReturn lifecycleOwner.lifecycle
+ on { resources } doReturn resourcesMock
+ on { isActivityTransitionRunning } doReturn true
+ on { window } doReturn windowMock
}
private val testSubject = EnterTransitionAnimationDelegate(activity) { transitionTargetView }
@@ -82,8 +83,8 @@ class EnterTransitionAnimationDelegateTest {
testSubject.markOffsetCalculated()
scheduler.advanceTimeBy(TIMEOUT_MS + 1L)
- verify(activity, times(1)).startPostponedEnterTransition()
- verify(windowMock, never()).setWindowAnimations(anyInt())
+ verify(activity) { 1 * { mock.startPostponedEnterTransition() } }
+ verify(windowMock) { 0 * { setWindowAnimations(any<Int>()) } }
}
@Test
@@ -101,12 +102,12 @@ class EnterTransitionAnimationDelegateTest {
@Test
fun test_postponeTransition_resume_animation_conditions() {
testSubject.postponeTransition()
- verify(activity, never()).startPostponedEnterTransition()
+ verify(activity) { 0 * { startPostponedEnterTransition() } }
testSubject.markOffsetCalculated()
- verify(activity, never()).startPostponedEnterTransition()
+ verify(activity) { 0 * { startPostponedEnterTransition() } }
testSubject.onAllTransitionElementsReady()
- verify(activity, times(1)).startPostponedEnterTransition()
+ verify(activity) { 1 * { startPostponedEnterTransition() } }
}
}
diff --git a/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt
new file mode 100644
index 00000000..b25f4036
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.intentresolver
+
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import java.util.concurrent.atomic.AtomicInteger
+
+class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = true) :
+ ResolverListAdapter.ResolverListCommunicator {
+ private val sendVoiceCounter = AtomicInteger()
+ private val updateProfileViewButtonCounter = AtomicInteger()
+
+ val sendVoiceCommandCount
+ get() = sendVoiceCounter.get()
+
+ override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent {
+ return defIntent
+ }
+
+ override fun onPostListReady(
+ listAdapter: ResolverListAdapter?,
+ updateUi: Boolean,
+ rebuildCompleted: Boolean,
+ ) = Unit
+
+ override fun sendVoiceChoicesIfNeeded() {
+ sendVoiceCounter.incrementAndGet()
+ }
+
+ override fun useLayoutWithDefault(): Boolean = layoutWithDefaults
+
+ override fun shouldGetActivityMetadata(): Boolean = true
+
+ override fun onHandlePackagesChanged(listAdapter: ResolverListAdapter?) {}
+}
diff --git a/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt
new file mode 100644
index 00000000..47db0cf5
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.data.repository.FakeUserRepository
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.shared.model.User
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class)
+class ProfileAvailabilityTest {
+ private val personalUser = User(0, User.Role.PERSONAL)
+ private val workUser = User(10, User.Role.WORK)
+
+ private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser)
+ private val workProfile = Profile(Profile.Type.WORK, workUser)
+
+ private val repository = FakeUserRepository(listOf(personalUser, workUser))
+ private val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
+
+ @Test
+ fun testProfileAvailable() = runTest {
+ val availability = ProfileAvailability(interactor, this, Dispatchers.IO)
+
+ assertThat(availability.isAvailable(personalProfile)).isTrue()
+ assertThat(availability.isAvailable(workProfile)).isTrue()
+
+ availability.requestQuietModeState(workProfile, true)
+ runCurrent()
+
+ assertThat(availability.isAvailable(workProfile)).isFalse()
+
+ availability.requestQuietModeState(workProfile, false)
+ runCurrent()
+
+ assertThat(availability.isAvailable(workProfile)).isTrue()
+ }
+
+ @Test
+ fun waitingToEnableProfile() = runTest {
+ val availability = ProfileAvailability(interactor, this, Dispatchers.IO)
+
+ availability.requestQuietModeState(workProfile, true)
+ assertThat(availability.waitingToEnableProfile).isFalse()
+ runCurrent()
+
+ availability.requestQuietModeState(workProfile, false)
+ assertThat(availability.waitingToEnableProfile).isTrue()
+ runCurrent()
+
+ assertThat(availability.waitingToEnableProfile).isFalse()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt
new file mode 100644
index 00000000..956c39e9
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2024 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.intentresolver
+
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.data.repository.FakeUserRepository
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.shared.model.User
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(JavaInterop::class)
+class ProfileHelperTest {
+
+ private val personalUser = User(0, User.Role.PERSONAL)
+ private val cloneUser = User(10, User.Role.CLONE)
+
+ private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser)
+ private val personalWithCloneProfile = Profile(Profile.Type.PERSONAL, personalUser, cloneUser)
+
+ private val workUser = User(11, User.Role.WORK)
+ private val workProfile = Profile(Profile.Type.WORK, workUser)
+
+ private val privateUser = User(12, User.Role.PRIVATE)
+ private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser)
+
+ private fun assertProfiles(
+ helper: ProfileHelper,
+ personalProfile: Profile,
+ workProfile: Profile? = null,
+ privateProfile: Profile? = null,
+ ) {
+ assertThat(helper.personalProfile).isEqualTo(personalProfile)
+ assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle)
+
+ personalProfile.clone?.also {
+ assertThat(helper.cloneUserPresent).isTrue()
+ assertThat(helper.cloneHandle).isEqualTo(it.handle)
+ }
+ ?: {
+ assertThat(helper.cloneUserPresent).isFalse()
+ assertThat(helper.cloneHandle).isNull()
+ }
+
+ workProfile?.also {
+ assertThat(helper.workProfilePresent).isTrue()
+ assertThat(helper.workProfile).isEqualTo(it)
+ assertThat(helper.workHandle).isEqualTo(it.primary.handle)
+ }
+ ?: {
+ assertThat(helper.workProfilePresent).isFalse()
+ assertThat(helper.workProfile).isNull()
+ assertThat(helper.workHandle).isNull()
+ }
+
+ privateProfile?.also {
+ assertThat(helper.privateProfilePresent).isTrue()
+ assertThat(helper.privateProfile).isEqualTo(it)
+ assertThat(helper.privateHandle).isEqualTo(it.primary.handle)
+ }
+ ?: {
+ assertThat(helper.privateProfilePresent).isFalse()
+ assertThat(helper.privateProfile).isNull()
+ assertThat(helper.privateHandle).isNull()
+ }
+ }
+
+ @Test
+ fun launchedByPersonal() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser))
+ val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL)
+ assertThat(helper.getQueryIntentsHandle(personalUser.handle))
+ .isEqualTo(personalProfile.primary.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByPersonal_withClone() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, cloneUser))
+ val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalWithCloneProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL)
+ assertThat(helper.getQueryIntentsHandle(personalUser.handle)).isEqualTo(personalUser.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByClone() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, cloneUser))
+ val interactor = UserInteractor(repository, launchedAs = cloneUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalWithCloneProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isTrue()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL)
+ assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle))
+ .isEqualTo(personalWithCloneProfile.clone?.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch)
+ .isEqualTo(personalWithCloneProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByPersonal_withWork() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, workUser))
+ val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile)
+
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL)
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.getQueryIntentsHandle(personalUser.handle))
+ .isEqualTo(personalProfile.primary.handle)
+ assertThat(helper.getQueryIntentsHandle(workUser.handle))
+ .isEqualTo(workProfile.primary.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByWork() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, workUser))
+ val interactor = UserInteractor(repository, launchedAs = workUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK)
+ assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle))
+ .isEqualTo(personalProfile.primary.handle)
+ assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle))
+ .isEqualTo(workProfile.primary.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(workProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByPersonal_withPrivate() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, privateUser))
+ val interactor = UserInteractor(repository, launchedAs = personalUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL)
+ assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle))
+ .isEqualTo(personalProfile.primary.handle)
+ assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle))
+ .isEqualTo(privateProfile.primary.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle)
+ }
+
+ @Test
+ fun launchedByPrivate() = runTest {
+ val repository = FakeUserRepository(listOf(personalUser, privateUser))
+ val interactor = UserInteractor(repository, launchedAs = privateUser.handle)
+
+ val helper = ProfileHelper(interactor = interactor, background = Dispatchers.Unconfined)
+
+ assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile)
+
+ assertThat(helper.isLaunchedAsCloneProfile).isFalse()
+ assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE)
+ assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle))
+ .isEqualTo(personalProfile.primary.handle)
+ assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle))
+ .isEqualTo(privateProfile.primary.handle)
+ assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt
new file mode 100644
index 00000000..23ea33b2
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt
@@ -0,0 +1,1066 @@
+/*
+ * 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.intentresolver
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.os.UserHandle
+import android.os.UserManager
+import android.view.LayoutInflater
+import com.android.intentresolver.ResolverDataProvider.createActivityInfo
+import com.android.intentresolver.ResolverListAdapter.ResolverListCommunicator
+import com.android.intentresolver.icons.TargetDataLoader
+import com.android.intentresolver.util.TestExecutor
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.inOrder
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+private const val PKG_NAME = "org.pkg.app"
+private const val PKG_NAME_TWO = "org.pkg.two.app"
+private const val PKG_NAME_THREE = "org.pkg.three.app"
+private const val CLASS_NAME = "org.pkg.app.TheClass"
+
+class ResolverListAdapterTest {
+ private val layoutInflater = mock<LayoutInflater>()
+ private val packageManager = mock<PackageManager>()
+ private val userManager = mock<UserManager> { on { isManagedProfile } doReturn (false) }
+ private val context =
+ mock<Context> {
+ on { getSystemService(Context.LAYOUT_INFLATER_SERVICE) } doReturn layoutInflater
+ on { getSystemService(Context.USER_SERVICE) } doReturn userManager
+ on { packageManager } doReturn this@ResolverListAdapterTest.packageManager
+ }
+ private val targetIntent = Intent(Intent.ACTION_SEND)
+ private val payloadIntents = listOf(targetIntent)
+ private val resolverListCommunicator = FakeResolverListCommunicator()
+ private val userHandle = UserHandle.of(UserHandle.USER_CURRENT)
+ private val targetDataLoader = mock<TargetDataLoader>()
+ private val backgroundExecutor = TestExecutor()
+ private val immediateExecutor = TestExecutor(immediate = true)
+
+ @Test
+ fun test_oneTargetNoLastChosen_oneTargetInAdapter() {
+ val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME))
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isTrue()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.placeholderCount).isEqualTo(0)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1)
+ }
+
+ @Test
+ fun test_oneTargetThatWasLastChosen_NoTargetsInAdapter() {
+ val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME))
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isTrue()
+ assertThat(testSubject.count).isEqualTo(0)
+ assertThat(testSubject.placeholderCount).isEqualTo(0)
+ assertThat(testSubject.hasFilteredItem()).isTrue()
+ assertThat(testSubject.filteredItem).isNotNull()
+ assertThat(testSubject.filteredPosition).isEqualTo(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun test_oneTargetLastChosenNotInTheList_oneTargetInAdapter() {
+ val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME))
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { lastChosen } doReturn createResolveInfo(PKG_NAME_TWO, CLASS_NAME, userHandle)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isTrue()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.placeholderCount).isEqualTo(0)
+ assertThat(testSubject.hasFilteredItem()).isTrue()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertWithMessage("unfilteredResolveList")
+ .that(testSubject.unfilteredResolveList)
+ .containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun test_oneTargetThatWasLastChosenFilteringDisabled_oneTargetInAdapter() {
+ val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME))
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ false,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isTrue()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ // we don't reset placeholder count
+ assertThat(testSubject.placeholderCount).isEqualTo(0)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertWithMessage("unfilteredResolveList")
+ .that(testSubject.unfilteredResolveList)
+ .containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ }
+
+ @Test
+ fun test_twoTargetsNoLastChosenUseLayoutWithDefaults_twoTargetsInAdapter() {
+ testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = true)
+ }
+
+ @Test
+ fun test_twoTargetsNoLastChosenDontUseLayoutWithDefaults_twoTargetsInAdapter() {
+ testTwoTargets(hasLastChosen = false, useLayoutWithDefaults = false)
+ }
+
+ @Test
+ fun test_twoTargetsLastChosenUseLayoutWithDefaults_oneTargetInAdapter() {
+ testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = true)
+ }
+
+ @Test
+ fun test_twoTargetsLastChosenDontUseLayoutWithDefaults_oneTargetInAdapter() {
+ testTwoTargets(hasLastChosen = true, useLayoutWithDefaults = false)
+ }
+
+ private fun testTwoTargets(hasLastChosen: Boolean, useLayoutWithDefaults: Boolean) {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ if (hasLastChosen) {
+ on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0)
+ }
+ }
+ val resolverListCommunicator = FakeResolverListCommunicator(useLayoutWithDefaults)
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ val placeholderCount = resolvedTargets.size - (if (useLayoutWithDefaults) 1 else 0)
+ assertThat(testSubject.count).isEqualTo(placeholderCount)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen)
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isFalse()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isEqualTo(hasLastChosen)
+ if (hasLastChosen) {
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size - 1)
+ assertThat(testSubject.filteredItem).isNotNull()
+ assertThat(testSubject.filteredPosition).isEqualTo(0)
+ } else {
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ }
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1)
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun test_twoTargetsLastChosenNotInTheList_twoTargetsInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { lastChosen } doReturn createResolveInfo(PKG_NAME, CLASS_NAME + "2", userHandle)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = false
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ val placeholderCount = resolvedTargets.size - 1
+ assertThat(testSubject.count).isEqualTo(placeholderCount)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isTrue()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isFalse()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isTrue()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun test_twoTargetsWithOtherProfileAndLastChosen_oneTargetInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ resolvedTargets[1].getResolveInfoAt(0).targetUserId = 10
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0)
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isTrue()
+ assertThat(testSubject.count).isEqualTo(1)
+ assertThat(testSubject.placeholderCount).isEqualTo(0)
+ assertThat(testSubject.otherProfile).isNotNull()
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ // The following must be an old bug i.e. unfilteredResolveList should be equal to
+ // resolvedTargets. Also see comments in the code.
+ assertThat(testSubject.unfilteredResolveList).containsExactly(resolvedTargets[0])
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun test_resultsSorted_appearInSortedOrderInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { sort(any()) } doAnswer
+ {
+ val components = it.arguments[0] as MutableList<ResolvedComponentInfo>
+ components[0] = components[1].also { components[1] = components[0] }
+ null
+ }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ testSubject.rebuildList(doPostProcessing)
+
+ backgroundExecutor.runUntilIdle()
+
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.getDisplayResolveInfo(0).resolveInfo.activityInfo.packageName)
+ .isEqualTo(PKG_NAME_TWO)
+ assertThat(testSubject.getDisplayResolveInfo(1).resolveInfo.activityInfo.packageName)
+ .isEqualTo(PKG_NAME)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun test_ineligibleActivityFilteredOut_filteredComponentNotPresentInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { filterIneligibleActivities(any(), any()) } doAnswer
+ {
+ val components = it.arguments[0] as MutableList<ResolvedComponentInfo>
+ val original = ArrayList(components)
+ components.removeAt(1)
+ original
+ }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ testSubject.rebuildList(doPostProcessing)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.count).isEqualTo(1)
+ assertThat(testSubject.getItem(0)?.resolveInfo)
+ .isEqualTo(resolvedTargets[0].getResolveInfoAt(0))
+ assertThat(testSubject.unfilteredResolveList).hasSize(2)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun test_baseResolveList_excludedFromIneligibleActivityFiltering() {
+ val rList = listOf(createResolveInfo(PKG_NAME, CLASS_NAME, userHandle))
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterLowPriority(any(), any()) } doReturn null
+ on { addResolveListDedupe(any(), eq(targetIntent), eq(rList)) } doAnswer
+ {
+ val result = it.arguments[0] as MutableList<ResolvedComponentInfo>
+ result.addAll(
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ )
+ null
+ }
+ on { filterIneligibleActivities(any(), any()) } doAnswer
+ {
+ val components = it.arguments[0] as MutableList<ResolvedComponentInfo>
+ val original = ArrayList(components)
+ components.clear()
+ original
+ }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ rList,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ testSubject.rebuildList(doPostProcessing)
+
+ backgroundExecutor.runUntilIdle()
+
+ assertThat(testSubject.count).isEqualTo(2)
+ assertThat(testSubject.unfilteredResolveList).hasSize(2)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun test_lowPriorityComponentFilteredOut_filteredComponentNotPresentInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ on { filterLowPriority(any(), any()) } doAnswer
+ {
+ val components = it.arguments[0] as MutableList<ResolvedComponentInfo>
+ val original = ArrayList(components)
+ components.removeAt(1)
+ original
+ }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ testSubject.rebuildList(doPostProcessing)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.count).isEqualTo(1)
+ assertThat(testSubject.getItem(0)?.resolveInfo)
+ .isEqualTo(resolvedTargets[0].getResolveInfoAt(0))
+ assertThat(testSubject.unfilteredResolveList).hasSize(2)
+ }
+
+ @Test
+ fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val initialComponent = ComponentName(PKG_NAME_THREE, CLASS_NAME)
+ val initialIntents =
+ arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent })
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ }
+ whenever(packageManager.getActivityInfo(eq(initialComponent), eq(0)))
+ .thenReturn(createActivityInfo(initialComponent))
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ val placeholderCount = resolvedTargets.size - 1
+ assertThat(testSubject.count).isEqualTo(placeholderCount)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isFalse()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size + initialIntents.size)
+ assertThat(testSubject.getItem(0)?.targetIntent?.component)
+ .isEqualTo(initialIntents[0].component)
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1)
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun test_twoTargetsWithOverlappingInitialIntent_twoTargetsInAdapter() {
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on {
+ getResolversForIntentAsUser(
+ true,
+ resolverListCommunicator.shouldGetActivityMetadata(),
+ resolverListCommunicator.shouldGetOnlyDefaultActivities(),
+ payloadIntents,
+ userHandle
+ )
+ } doReturn ArrayList(resolvedTargets)
+ }
+ val initialComponent = ComponentName(PKG_NAME_TWO, CLASS_NAME)
+ val initialIntents =
+ arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent })
+ whenever(packageManager.getActivityInfo(eq(initialComponent), eq(0)))
+ .thenReturn(createActivityInfo(initialComponent))
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = true
+
+ val isLoaded = testSubject.rebuildList(doPostProcessing)
+
+ assertThat(isLoaded).isFalse()
+ val placeholderCount = resolvedTargets.size - 1
+ assertThat(testSubject.count).isEqualTo(placeholderCount)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isFalse()
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1)
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0)
+
+ backgroundExecutor.runUntilIdle()
+
+ // we don't reset placeholder count (legacy logic, likely an oversight?)
+ assertThat(testSubject.placeholderCount).isEqualTo(placeholderCount)
+ assertThat(testSubject.hasFilteredItem()).isFalse()
+ assertThat(testSubject.count).isEqualTo(resolvedTargets.size)
+ assertThat(testSubject.getItem(0)?.targetIntent?.component)
+ .isEqualTo(initialIntents[0].component)
+ assertThat(testSubject.filteredItem).isNull()
+ assertThat(testSubject.filteredPosition).isLessThan(0)
+ assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets)
+ assertThat(testSubject.isTabLoaded).isTrue()
+ assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1)
+ assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0)
+ }
+
+ @Test
+ fun testPostListReadyAtEndOfRebuild_synchronous() {
+ val communicator = mock<ResolverListCommunicator> {}
+ val resolverListController = mock<ResolverListController>()
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ communicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = false
+
+ testSubject.rebuildList(doPostProcessing)
+
+ verify(communicator).onPostListReady(testSubject, doPostProcessing, true)
+ }
+
+ @Test
+ fun testPostListReadyAtEndOfRebuild_stages() {
+ // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks.
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn
+ ArrayList(resolvedTargets)
+ }
+ val communicator =
+ mock<ResolverListCommunicator> {
+ on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ communicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ immediateExecutor,
+ )
+ val doPostProcessing = false
+
+ testSubject.rebuildList(doPostProcessing)
+
+ backgroundExecutor.runUntilIdle()
+
+ val inOrder = inOrder(communicator)
+ inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, false)
+ inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, true)
+ }
+
+ @Test
+ fun testPostListReadyAtEndOfRebuild_queued() {
+ val queuedCallbacksExecutor = TestExecutor()
+
+ // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks.
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn
+ ArrayList(resolvedTargets)
+ }
+ val communicator =
+ mock<ResolverListCommunicator> {
+ on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ communicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ queuedCallbacksExecutor
+ )
+ val doPostProcessing = false
+ testSubject.rebuildList(doPostProcessing)
+
+ // Finish all the background work (enqueueing both the "partial" and "complete" progress
+ // callbacks) before dequeueing either callback.
+ backgroundExecutor.runUntilIdle()
+ queuedCallbacksExecutor.runUntilIdle()
+
+ // TODO: we may not necessarily care to assert that there's a "partial progress" callback in
+ // this case, since there won't be a chance to reflect the "partial" state in the UI before
+ // the "completion" is queued (and if we depend on seeing an intermediate state, that could
+ // be a bad sign for our handling in the "synchronous" case?). But we should probably at
+ // least assert that the "partial" callback never arrives *after* the completion?
+ val inOrder = inOrder(communicator)
+ inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, false)
+ inOrder.verify(communicator).onPostListReady(testSubject, doPostProcessing, true)
+ }
+
+ @Test
+ fun testPostListReadyAtEndOfRebuild_skippedIfStillQueuedOnDestroy() {
+ val queuedCallbacksExecutor = TestExecutor()
+
+ // We need at least two targets to trigger asynchronous sorting/"staged" progress callbacks.
+ val resolvedTargets =
+ createResolvedComponents(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ ComponentName(PKG_NAME_TWO, CLASS_NAME),
+ )
+ val resolverListController =
+ mock<ResolverListController> {
+ on { filterIneligibleActivities(any(), any()) } doReturn null
+ on { filterLowPriority(any(), any()) } doReturn null
+ on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn
+ ArrayList(resolvedTargets)
+ }
+ val communicator =
+ mock<ResolverListCommunicator> {
+ on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent }
+ }
+ val testSubject =
+ ResolverListAdapter(
+ context,
+ payloadIntents,
+ /*initialIntents=*/ null,
+ /*rList=*/ null,
+ /*filterLastUsed=*/ true,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ communicator,
+ /*initialIntentsUserSpace=*/ userHandle,
+ targetDataLoader,
+ backgroundExecutor,
+ queuedCallbacksExecutor
+ )
+ val doPostProcessing = false
+ testSubject.rebuildList(doPostProcessing)
+
+ // Finish all the background work (enqueueing both the "partial" and "complete" progress
+ // callbacks) before dequeueing either callback.
+ backgroundExecutor.runUntilIdle()
+
+ // Notify that our activity is being destroyed while the callbacks are still queued.
+ testSubject.onDestroy()
+
+ queuedCallbacksExecutor.runUntilIdle()
+
+ verify(communicator, never()).onPostListReady(eq(testSubject), eq(doPostProcessing), any())
+ }
+
+ private fun createResolvedComponents(
+ vararg components: ComponentName
+ ): List<ResolvedComponentInfo> {
+ val result = ArrayList<ResolvedComponentInfo>(components.size)
+ for (component in components) {
+ val resolvedComponentInfo =
+ ResolvedComponentInfo(
+ ComponentName(PKG_NAME, CLASS_NAME),
+ targetIntent,
+ createResolveInfo(component.packageName, component.className, userHandle)
+ )
+ result.add(resolvedComponentInfo)
+ }
+ return result
+ }
+
+ private fun createResolveInfo(
+ packageName: String,
+ className: String,
+ handle: UserHandle,
+ label: String? = null
+ ): ResolveInfo =
+ ResolveInfo().apply {
+ activityInfo = createActivityInfo(ComponentName(packageName, className))
+ targetUserId = handle.identifier
+ userHandle = handle
+ nonLocalizedLabel = label
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
new file mode 100644
index 00000000..d591d928
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
@@ -0,0 +1,388 @@
+/*
+ * Copyright (C) 2022 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.intentresolver
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.os.UserHandle
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import android.service.chooser.ChooserTarget
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.Flags.FLAG_REBUILD_ADAPTERS_ON_TARGET_PINNING
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.chooser.TargetInfo
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+private const val PACKAGE_A = "package.a"
+private const val PACKAGE_B = "package.b"
+private const val CLASS_NAME = "./MainActivity"
+
+private val PERSONAL_USER_HANDLE: UserHandle =
+ InstrumentationRegistry.getInstrumentation().targetContext.user
+
+@SmallTest
+class ShortcutSelectionLogicTest {
+ @get:Rule val flagRule = SetFlagsRule()
+
+ private val packageTargets =
+ HashMap<String, Array<ChooserTarget>>().apply {
+ arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg ->
+ // shortcuts in reverse priority order
+ val targets =
+ Array(3) { i ->
+ createChooserTarget(
+ "Shortcut $i",
+ (i + 1).toFloat() / 10f,
+ ComponentName(pkg, CLASS_NAME),
+ pkg.shortcutId(i),
+ )
+ }
+ this[pkg] = targets
+ }
+ }
+ private val targetInfoChooserTargetCorrespondence =
+ Correspondence.from<TargetInfo, ChooserTarget>(
+ { actual, expected ->
+ actual.chooserTargetComponentName == expected.componentName &&
+ actual.displayLabel == expected.title
+ },
+ "",
+ )
+
+ private val baseDisplayInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
+ "label",
+ "extended info",
+ Intent(),
+ )
+
+ private val otherBaseDisplayInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE),
+ "label 2",
+ "extended info 2",
+ Intent(),
+ )
+
+ private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) =
+ this[pkg]?.get(idx) ?: error("missing package $pkg")
+
+ @Test
+ fun testAddShortcuts_no_limits() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false,
+ )
+
+ val isUpdated =
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Updates are expected").that(isUpdated).isTrue()
+ assertWithMessage("Two shortcuts are expected as we do not apply per-app shortcut limit")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc2, sc1)
+ .inOrder()
+ }
+
+ @Test
+ fun testAddShortcuts_same_package_with_per_package_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true,
+ )
+
+ val isUpdated =
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Updates are expected").that(isUpdated).isTrue()
+ assertWithMessage("One shortcut is expected as we apply per-app shortcut limit")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc2)
+ .inOrder()
+ }
+
+ @Test
+ fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false,
+ )
+
+ val isUpdated =
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 1,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Updates are expected").that(isUpdated).isTrue()
+ assertWithMessage("One shortcut is expected as we apply overall shortcut limit")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc2)
+ .inOrder()
+ }
+
+ @Test
+ fun testAddShortcuts_different_packages_with_per_package_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val pkgAsc1 = packageTargets[PACKAGE_A, 0]
+ val pkgAsc2 = packageTargets[PACKAGE_A, 1]
+ val pkgBsc1 = packageTargets[PACKAGE_B, 0]
+ val pkgBsc2 = packageTargets[PACKAGE_B, 1]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true,
+ )
+
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(pkgAsc1, pkgAsc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+ testSubject.addServiceResults(
+ /* origTarget = */ otherBaseDisplayInfo,
+ /* origTargetScore = */ 0.2f,
+ /* targets = */ listOf(pkgBsc1, pkgBsc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Two shortcuts are expected as we apply per-app shortcut limit")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(pkgBsc2, pkgAsc2)
+ .inOrder()
+ }
+
+ @Test
+ fun testAddShortcuts_pinned_shortcut() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false,
+ )
+
+ val isUpdated =
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ mapOf(
+ sc1 to
+ createShortcutInfo(PACKAGE_A.shortcutId(1), sc1.componentName, 1).apply {
+ addFlags(ShortcutInfo.FLAG_PINNED)
+ }
+ ),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Updates are expected").that(isUpdated).isTrue()
+ assertWithMessage("Two shortcuts are expected as we do not apply per-app shortcut limit")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc1, sc2)
+ .inOrder()
+ }
+
+ @Test
+ fun test_available_caller_shortcuts_count_is_limited() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val sc3 = packageTargets[PACKAGE_A, 2]
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true,
+ )
+ val context = mock<Context> { on { packageManager } doReturn (mock()) }
+
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0f,
+ /* targets = */ listOf(sc1, sc2, sc3),
+ /* isShortcutResult = */ false,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ context,
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("At most two caller-provided shortcuts are allowed")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc3, sc2)
+ .inOrder()
+ }
+
+ @Test
+ @EnableFlags(FLAG_REBUILD_ADAPTERS_ON_TARGET_PINNING)
+ fun addServiceResults_sameShortcutWithDifferentPinnedStatus_shortcutUpdated() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 =
+ createChooserTarget(
+ title = "Shortcut",
+ score = 1f,
+ ComponentName(PACKAGE_A, CLASS_NAME),
+ PACKAGE_A.shortcutId(0),
+ )
+ val sc2 =
+ createChooserTarget(
+ title = "Shortcut",
+ score = 1f,
+ ComponentName(PACKAGE_A, CLASS_NAME),
+ PACKAGE_A.shortcutId(0),
+ )
+ val testSubject =
+ ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false,
+ )
+
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ mapOf(
+ sc1 to createShortcutInfo(PACKAGE_A.shortcutId(1), sc1.componentName, 1)
+ ),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+ val isUpdated =
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ mapOf(
+ sc1 to
+ createShortcutInfo(PACKAGE_A.shortcutId(1), sc1.componentName, 1).apply {
+ addFlags(ShortcutInfo.FLAG_PINNED)
+ }
+ ),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults,
+ )
+
+ assertWithMessage("Updates are expected").that(isUpdated).isTrue()
+ assertWithMessage("Updated shortcut is expected")
+ .that(serviceResults)
+ .comparingElementsUsing(targetInfoChooserTargetCorrespondence)
+ .containsExactly(sc2)
+ .inOrder()
+ assertThat(serviceResults[0].isPinned).isTrue()
+ }
+
+ private fun String.shortcutId(id: Int) = "$this.$id"
+}
diff --git a/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt
new file mode 100644
index 00000000..b5b05eb9
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2022 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.intentresolver
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Unit tests for the various implementations of {@link TargetPresentationGetter}.
+ *
+ * TODO: consider expanding to cover icon logic (not just labels/sublabels).
+ * TODO: these are conceptually "acceptance tests" that provide comprehensive coverage of the
+ * apparent variations in the legacy implementation. The tests probably don't have to be so
+ * exhaustive if we're able to impose a simpler design on the implementation.
+ */
+class TargetPresentationGetterTest {
+ fun makeResolveInfoPresentationGetter(
+ withSubstitutePermission: Boolean,
+ appLabel: String,
+ activityLabel: String,
+ resolveInfoLabel: String,
+ ): TargetPresentationGetter {
+ val testPackageInfo =
+ ResolverDataProvider.createPackageManagerMockedInfo(
+ withSubstitutePermission,
+ appLabel,
+ activityLabel,
+ resolveInfoLabel,
+ )
+ val factory =
+ TargetPresentationGetter.Factory(
+ { SimpleIconFactory.obtain(testPackageInfo.ctx) },
+ testPackageInfo.ctx.packageManager,
+ 100,
+ )
+ return factory.makePresentationGetter(testPackageInfo.resolveInfo)
+ }
+
+ fun makeActivityInfoPresentationGetter(
+ withSubstitutePermission: Boolean,
+ appLabel: String?,
+ activityLabel: String?,
+ ): TargetPresentationGetter {
+ val testPackageInfo =
+ ResolverDataProvider.createPackageManagerMockedInfo(
+ withSubstitutePermission,
+ appLabel,
+ activityLabel,
+ "",
+ )
+ val factory =
+ TargetPresentationGetter.Factory(
+ { SimpleIconFactory.obtain(testPackageInfo.ctx) },
+ testPackageInfo.ctx.packageManager,
+ 100,
+ )
+ return factory.makePresentationGetter(testPackageInfo.activityInfo)
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeActivityInfoPresentationGetter(false, "app_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, there's no logic to dedupe the labels.
+ // TODO: this matches our observations in the legacy code, but is it the right behavior? It
+ // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
+ // the UI at least, but maybe that logic should be pulled back to the "presentation"?
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_nullRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, null, "activity_label")
+ assertThat(presentationGetter.getLabel()).isNull()
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, "", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, empty sublabels are passed through as-is.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("")
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeActivityInfoPresentationGetter(true, "app_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, the same ("activity") label is requested as both the
+ // label
+ // and sublabel, even though the other value ("app_label") was distinct. Thus this behaves
+ // the
+ // same as a dupe.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo(null)
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // With the substitute permission, duped sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_nullRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", null)
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // With the substitute permission, null inputs are a special case that produces null outputs
+ // (i.e., they're not simply passed-through from the inputs).
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "")
+ // Empty "labels" are taken as-is and (unlike nulls) don't prompt a fallback to the
+ // sublabel.
+ // Thus (as in the previous case with substitute permission & "distinct" labels), this is
+ // treated as a dupe.
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(
+ false,
+ "app_label",
+ "activity_label",
+ "resolve_info_label",
+ )
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(false, "app_label", "activity_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, there's no logic to dedupe the labels.
+ // TODO: this matches our observations in the legacy code, but is it the right behavior? It
+ // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
+ // the UI at least, but maybe that logic should be pulled back to the "presentation"?
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(false, "app_label", "activity_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, empty sublabels are passed through as-is.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("")
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(
+ true,
+ "app_label",
+ "activity_label",
+ "resolve_info_label",
+ )
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(true, "app_label", "activity_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, duped sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter =
+ makeResolveInfoPresentationGetter(true, "app_label", "activity_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(true, "app_label", "", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/tests/unit/src/com/android/intentresolver/TestHelpers.kt
index 5b583fef..812ecd1b 100644
--- a/java/tests/src/com/android/intentresolver/TestHelpers.kt
+++ b/tests/unit/src/com/android/intentresolver/TestHelpers.kt
@@ -25,25 +25,17 @@ import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager.ShareShortcutInfo
import android.os.Bundle
import android.service.chooser.ChooserTarget
-import org.mockito.Mockito.`when` as whenever
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
internal fun createShareShortcutInfo(
id: String,
componentName: ComponentName,
rank: Int
-): ShareShortcutInfo =
- ShareShortcutInfo(
- createShortcutInfo(id, componentName, rank),
- componentName
- )
+): ShareShortcutInfo = ShareShortcutInfo(createShortcutInfo(id, componentName, rank), componentName)
-internal fun createShortcutInfo(
- id: String,
- componentName: ComponentName,
- rank: Int
-): ShortcutInfo {
- val context = mock<Context>()
- whenever(context.packageName).thenReturn(componentName.packageName)
+internal fun createShortcutInfo(id: String, componentName: ComponentName, rank: Int): ShortcutInfo {
+ val context = mock<Context> { on { packageName } doReturn componentName.packageName }
return ShortcutInfo.Builder(context, id)
.setShortLabel("Short Label $id")
.setLongLabel("Long Label $id")
@@ -60,7 +52,10 @@ internal fun createAppTarget(shortcutInfo: ShortcutInfo) =
)
fun createChooserTarget(
- title: String, score: Float, componentName: ComponentName, shortcutId: String
+ title: String,
+ score: Float,
+ componentName: ComponentName,
+ shortcutId: String
): ChooserTarget =
ChooserTarget(
title,
diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
index 504cfd97..4d9d4880 100644
--- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
+++ b/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
@@ -21,20 +21,19 @@ import android.app.prediction.AppTarget
import android.app.prediction.AppTargetId
import android.content.ComponentName
import android.content.Intent
-import android.content.pm.ResolveInfo
import android.os.Bundle
import android.os.UserHandle
-import com.android.intentresolver.createShortcutInfo
-import com.android.intentresolver.mock
+import androidx.test.platform.app.InstrumentationRegistry
import com.android.intentresolver.ResolverActivity
import com.android.intentresolver.ResolverDataProvider
+import com.android.intentresolver.createShortcutInfo
import com.google.common.truth.Truth.assertThat
import org.junit.Test
-import androidx.test.platform.app.InstrumentationRegistry
+import org.mockito.kotlin.mock
class ImmutableTargetInfoTest {
- private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry
- .getInstrumentation().getTargetContext().getUser()
+ private val PERSONAL_USER_HANDLE: UserHandle =
+ InstrumentationRegistry.getInstrumentation().getTargetContext().getUser()
private val resolvedIntent = Intent("resolved")
private val targetIntent = Intent("target")
@@ -47,61 +46,62 @@ class ImmutableTargetInfoTest {
private val displayIconHolder: TargetInfo.IconHolder = mock()
private val sourceIntent1 = Intent("source1")
private val sourceIntent2 = Intent("source2")
- private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo(
- Intent("display1"),
- ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE),
- "display1 label",
- "display1 extended info",
- Intent("display1_resolved"),
- /* resolveInfoPresentationGetter= */ null)
- private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo(
- Intent("display2"),
- ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
- "display2 label",
- "display2 extended info",
- Intent("display2_resolved"),
- /* resolveInfoPresentationGetter= */ null)
- private val directShareShortcutInfo = createShortcutInfo(
- "shortcutid", ResolverDataProvider.createComponentName(4), 4)
- private val directShareAppTarget = AppTarget(
- AppTargetId("apptargetid"),
- "test.directshare",
- "target",
- UserHandle.CURRENT)
- private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo(
- Intent("displayresolve"),
- ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE),
- "displayresolve label",
- "displayresolve extended info",
- Intent("display_resolved"),
- /* resolveInfoPresentationGetter= */ null)
+ private val displayTarget1 =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent("display1"),
+ ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE),
+ "display1 label",
+ "display1 extended info",
+ Intent("display1_resolved")
+ )
+ private val displayTarget2 =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent("display2"),
+ ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE),
+ "display2 label",
+ "display2 extended info",
+ Intent("display2_resolved")
+ )
+ private val directShareShortcutInfo =
+ createShortcutInfo("shortcutid", ResolverDataProvider.createComponentName(4), 4)
+ private val directShareAppTarget =
+ AppTarget(AppTargetId("apptargetid"), "test.directshare", "target", UserHandle.CURRENT)
+ private val displayResolveInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent("displayresolve"),
+ ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE),
+ "displayresolve label",
+ "displayresolve extended info",
+ Intent("display_resolved")
+ )
private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock()
@Test
- fun testBasicProperties() { // Fields that are reflected back w/o logic.
+ fun testBasicProperties() { // Fields that are reflected back w/o logic.
// TODO: we could consider passing copies of all the values into the builder so that we can
// verify that they're not mutated (e.g. no extras added to the intents). For now that
// should be obvious from the implementation.
- val info = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(resolvedIntent)
- .setTargetIntent(targetIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .setResolvedComponentName(resolvedComponentName)
- .setChooserTargetComponentName(chooserTargetComponentName)
- .setResolveInfo(resolveInfo)
- .setDisplayLabel(displayLabel)
- .setExtendedInfo(extendedInfo)
- .setDisplayIconHolder(displayIconHolder)
- .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
- .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
- .setIsSuspended(true)
- .setIsPinned(true)
- .setModifiedScore(42.0f)
- .setDirectShareShortcutInfo(directShareShortcutInfo)
- .setDirectShareAppTarget(directShareAppTarget)
- .setDisplayResolveInfo(displayResolveInfo)
- .setHashProvider(hashProvider)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(resolvedIntent)
+ .setTargetIntent(targetIntent)
+ .setReferrerFillInIntent(referrerFillInIntent)
+ .setResolvedComponentName(resolvedComponentName)
+ .setChooserTargetComponentName(chooserTargetComponentName)
+ .setResolveInfo(resolveInfo)
+ .setDisplayLabel(displayLabel)
+ .setExtendedInfo(extendedInfo)
+ .setDisplayIconHolder(displayIconHolder)
+ .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
+ .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
+ .setIsSuspended(true)
+ .setIsPinned(true)
+ .setModifiedScore(42.0f)
+ .setDirectShareShortcutInfo(directShareShortcutInfo)
+ .setDirectShareAppTarget(directShareAppTarget)
+ .setDisplayResolveInfo(displayResolveInfo)
+ .setHashProvider(hashProvider)
+ .build()
assertThat(info.resolvedIntent).isEqualTo(resolvedIntent)
assertThat(info.targetIntent).isEqualTo(targetIntent)
@@ -112,8 +112,8 @@ class ImmutableTargetInfoTest {
assertThat(info.displayLabel).isEqualTo(displayLabel)
assertThat(info.extendedInfo).isEqualTo(extendedInfo)
assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
- assertThat(info.allSourceIntents).containsExactly(
- resolvedIntent, sourceIntent1, sourceIntent2)
+ assertThat(info.allSourceIntents)
+ .containsExactly(resolvedIntent, sourceIntent1, sourceIntent2)
assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
assertThat(info.isSuspended).isTrue()
assertThat(info.isPinned).isTrue()
@@ -135,26 +135,27 @@ class ImmutableTargetInfoTest {
fun testToBuilderPreservesBasicProperties() {
// Note this is set up exactly as in `testBasicProperties`, but the assertions will be made
// against a *copy* of the object instead.
- val infoToCopyFrom = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(resolvedIntent)
- .setTargetIntent(targetIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .setResolvedComponentName(resolvedComponentName)
- .setChooserTargetComponentName(chooserTargetComponentName)
- .setResolveInfo(resolveInfo)
- .setDisplayLabel(displayLabel)
- .setExtendedInfo(extendedInfo)
- .setDisplayIconHolder(displayIconHolder)
- .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
- .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
- .setIsSuspended(true)
- .setIsPinned(true)
- .setModifiedScore(42.0f)
- .setDirectShareShortcutInfo(directShareShortcutInfo)
- .setDirectShareAppTarget(directShareAppTarget)
- .setDisplayResolveInfo(displayResolveInfo)
- .setHashProvider(hashProvider)
- .build()
+ val infoToCopyFrom =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(resolvedIntent)
+ .setTargetIntent(targetIntent)
+ .setReferrerFillInIntent(referrerFillInIntent)
+ .setResolvedComponentName(resolvedComponentName)
+ .setChooserTargetComponentName(chooserTargetComponentName)
+ .setResolveInfo(resolveInfo)
+ .setDisplayLabel(displayLabel)
+ .setExtendedInfo(extendedInfo)
+ .setDisplayIconHolder(displayIconHolder)
+ .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
+ .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
+ .setIsSuspended(true)
+ .setIsPinned(true)
+ .setModifiedScore(42.0f)
+ .setDirectShareShortcutInfo(directShareShortcutInfo)
+ .setDirectShareAppTarget(directShareAppTarget)
+ .setDisplayResolveInfo(displayResolveInfo)
+ .setHashProvider(hashProvider)
+ .build()
val info = infoToCopyFrom.toBuilder().build()
@@ -167,8 +168,8 @@ class ImmutableTargetInfoTest {
assertThat(info.displayLabel).isEqualTo(displayLabel)
assertThat(info.extendedInfo).isEqualTo(extendedInfo)
assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
- assertThat(info.allSourceIntents).containsExactly(
- resolvedIntent, sourceIntent1, sourceIntent2)
+ assertThat(info.allSourceIntents)
+ .containsExactly(resolvedIntent, sourceIntent1, sourceIntent2)
assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
assertThat(info.isSuspended).isTrue()
assertThat(info.isPinned).isTrue()
@@ -200,12 +201,13 @@ class ImmutableTargetInfoTest {
val referrerFillInIntent = Intent("REFERRER_FILL_IN")
referrerFillInIntent.setPackage("referrer")
- val info = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(originalIntent)
+ .setReferrerFillInIntent(referrerFillInIntent)
+ .build()
- assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty.
+ assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty.
assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN")
}
@@ -217,13 +219,12 @@ class ImmutableTargetInfoTest {
val refinementIntent = Intent()
refinementIntent.putExtra("REFINEMENT", true)
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .build()
- val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent)
+ val originalInfo =
+ ImmutableTargetInfo.newBuilder().setResolvedIntent(originalIntent).build()
+ val info = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent))
- assertThat(info?.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue()
- assertThat(info?.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue()
+ assertThat(info.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue()
+ assertThat(info.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue()
}
@Test
@@ -235,19 +236,21 @@ class ImmutableTargetInfoTest {
referrerFillInIntent.setPackage("referrer_pkg")
referrerFillInIntent.setType("test/referrer")
- val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
+ val infoWithReferrerFillIn =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(originalIntent)
+ .setReferrerFillInIntent(referrerFillInIntent)
+ .build()
val refinementIntent = Intent("REFINE_ME")
- refinementIntent.setPackage("original") // Has to match for refinement.
+ refinementIntent.setPackage("original") // Has to match for refinement.
- val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent)
+ val info =
+ checkNotNull(infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent))
- assertThat(info?.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along.
- assertThat(info?.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins.
- assertThat(info?.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer.
+ assertThat(info.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along.
+ assertThat(info.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins.
+ assertThat(info.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer.
}
@Test
@@ -260,24 +263,26 @@ class ImmutableTargetInfoTest {
val refinementIntent2 = Intent("REFINE_ME")
refinementIntent2.putExtra("TEST2", "2")
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setReferrerFillInIntent(referrerFillInIntent)
- .build()
+ val originalInfo =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(originalIntent)
+ .setReferrerFillInIntent(referrerFillInIntent)
+ .build()
- val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1)
- val refined2 = refined1?.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone.
+ val refined1 = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1))
+ // Cloned clone.
+ val refined2 = checkNotNull(refined1.tryToCloneWithAppliedRefinement(refinementIntent2))
// Both clones get the same values filled in from the referrer intent.
- assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
- assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
+ assertThat(refined1.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
+ assertThat(refined2.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
// Each clone has the respective value that was set in their own refinement request.
- assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1")
- assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2")
+ assertThat(refined1.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1")
+ assertThat(refined2.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2")
// The clones don't have the data from each other's refinements, even though the intent
// field is empty (thus able to be populated by filling-in).
- assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST2")).isNull()
- assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST1")).isNull()
+ assertThat(refined1.baseIntentToSend?.getStringExtra("TEST2")).isNull()
+ assertThat(refined2.baseIntentToSend?.getStringExtra("TEST1")).isNull()
}
@Test
@@ -291,25 +296,27 @@ class ImmutableTargetInfoTest {
val extraMatch = Intent("REFINE_ME")
extraMatch.putExtra("extraMatch", true)
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setAllSourceIntents(listOf(
- originalIntent, mismatchedAlternate, targetAlternate, extraMatch))
- .build()
+ val originalInfo =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(originalIntent)
+ .setAllSourceIntents(
+ listOf(originalIntent, mismatchedAlternate, targetAlternate, extraMatch)
+ )
+ .build()
- val refinement = Intent("REFINE_ME") // First match is `targetAlternate`
+ val refinement = Intent("REFINE_ME") // First match is `targetAlternate`
refinement.putExtra("refinement", true)
- val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue()
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("targetAlternate", false))
+ val refinedResult = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinement))
+ assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue()
+ assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("targetAlternate", false))
.isTrue()
// None of the other source intents got merged in (not even the later one that matched):
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("originalIntent", false))
+ assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("originalIntent", false))
.isFalse()
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false))
+ assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false))
.isFalse()
- assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse()
+ assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse()
}
@Test
@@ -319,10 +326,11 @@ class ImmutableTargetInfoTest {
val mismatchedAlternate = Intent("DOESNT_MATCH")
mismatchedAlternate.putExtra("mismatchedAlternate", true)
- val originalInfo = ImmutableTargetInfo.newBuilder()
- .setResolvedIntent(originalIntent)
- .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate))
- .build()
+ val originalInfo =
+ ImmutableTargetInfo.newBuilder()
+ .setResolvedIntent(originalIntent)
+ .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate))
+ .build()
val refinement = Intent("PROPOSED_REFINEMENT")
assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
@@ -330,9 +338,10 @@ class ImmutableTargetInfoTest {
@Test
fun testLegacySubclassRelationships_empty() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
+ .build()
assertThat(info.isEmptyTargetInfo).isTrue()
assertThat(info.isPlaceHolderTargetInfo).isFalse()
@@ -345,9 +354,10 @@ class ImmutableTargetInfoTest {
@Test
fun testLegacySubclassRelationships_placeholder() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
+ .build()
assertThat(info.isEmptyTargetInfo).isFalse()
assertThat(info.isPlaceHolderTargetInfo).isTrue()
@@ -360,9 +370,10 @@ class ImmutableTargetInfoTest {
@Test
fun testLegacySubclassRelationships_selectable() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO)
+ .build()
assertThat(info.isEmptyTargetInfo).isFalse()
assertThat(info.isPlaceHolderTargetInfo).isFalse()
@@ -375,9 +386,10 @@ class ImmutableTargetInfoTest {
@Test
fun testLegacySubclassRelationships_displayResolveInfo() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO)
+ .build()
assertThat(info.isEmptyTargetInfo).isFalse()
assertThat(info.isPlaceHolderTargetInfo).isFalse()
@@ -390,9 +402,10 @@ class ImmutableTargetInfoTest {
@Test
fun testLegacySubclassRelationships_multiDisplayResolveInfo() {
- val info = ImmutableTargetInfo.newBuilder()
- .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO)
- .build()
+ val info =
+ ImmutableTargetInfo.newBuilder()
+ .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO)
+ .build()
assertThat(info.isEmptyTargetInfo).isFalse()
assertThat(info.isPlaceHolderTargetInfo).isFalse()
@@ -405,13 +418,17 @@ class ImmutableTargetInfoTest {
@Test
fun testActivityStarter_correctNumberOfInvocations_startAsCaller() {
- val activityStarter = object : TestActivityStarter() {
- override fun startAsUser(
- target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle
- ): Boolean {
- throw RuntimeException("Wrong API used: startAsUser")
+ val activityStarter =
+ object : TestActivityStarter() {
+ override fun startAsUser(
+ target: TargetInfo,
+ activity: Activity,
+ options: Bundle,
+ user: UserHandle
+ ): Boolean {
+ throw RuntimeException("Wrong API used: startAsUser")
+ }
}
- }
val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
val activity: ResolverActivity = mock()
@@ -430,12 +447,17 @@ class ImmutableTargetInfoTest {
@Test
fun testActivityStarter_correctNumberOfInvocations_startAsUser() {
- val activityStarter = object : TestActivityStarter() {
- override fun startAsCaller(
- target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean {
- throw RuntimeException("Wrong API used: startAsCaller")
+ val activityStarter =
+ object : TestActivityStarter() {
+ override fun startAsCaller(
+ target: TargetInfo,
+ activity: Activity,
+ options: Bundle,
+ userId: Int
+ ): Boolean {
+ throw RuntimeException("Wrong API used: startAsCaller")
+ }
}
- }
val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
val activity: Activity = mock()
@@ -465,7 +487,7 @@ class ImmutableTargetInfoTest {
info2.startAsUser(mock(), Bundle(), UserHandle.of(42))
assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2)
- assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared.
+ assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared.
}
}
@@ -474,27 +496,35 @@ private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStart
var lastInvocationTargetInfo: TargetInfo? = null
var lastInvocationActivity: Activity? = null
var lastInvocationOptions: Bundle? = null
- var lastInvocationUserId: Integer? = null
+ var lastInvocationUserId: Int? = null
var lastInvocationAsCaller = false
override fun startAsCaller(
- target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean {
+ target: TargetInfo,
+ activity: Activity,
+ options: Bundle,
+ userId: Int
+ ): Boolean {
++totalInvocations
lastInvocationTargetInfo = target
lastInvocationActivity = activity
lastInvocationOptions = options
- lastInvocationUserId = Integer(userId)
+ lastInvocationUserId = userId
lastInvocationAsCaller = true
return true
}
override fun startAsUser(
- target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean {
+ target: TargetInfo,
+ activity: Activity,
+ options: Bundle,
+ user: UserHandle
+ ): Boolean {
++totalInvocations
lastInvocationTargetInfo = target
lastInvocationActivity = activity
lastInvocationOptions = options
- lastInvocationUserId = Integer(user.identifier)
+ lastInvocationUserId = user.identifier
lastInvocationAsCaller = false
return true
}
diff --git a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt
new file mode 100644
index 00000000..a2f6e7a4
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt
@@ -0,0 +1,437 @@
+/*
+ * Copyright (C) 2022 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.intentresolver.chooser
+
+import android.app.prediction.AppTarget
+import android.app.prediction.AppTargetId
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.ResolveInfo
+import android.graphics.drawable.AnimatedVectorDrawable
+import android.os.UserHandle
+import androidx.test.annotation.UiThreadTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.ResolverDataProvider
+import com.android.intentresolver.ResolverDataProvider.createResolveInfo
+import com.android.intentresolver.createChooserTarget
+import com.android.intentresolver.createShortcutInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+class TargetInfoTest {
+ private val PERSONAL_USER_HANDLE: UserHandle =
+ InstrumentationRegistry.getInstrumentation().getTargetContext().getUser()
+
+ private val context = InstrumentationRegistry.getInstrumentation().getContext()
+
+ @Before
+ fun setup() {
+ // SelectableTargetInfo reads DeviceConfig and needs a permission for that.
+ InstrumentationRegistry.getInstrumentation()
+ .uiAutomation
+ .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG")
+ }
+
+ @Test
+ fun testNewEmptyTargetInfo() {
+ val info = NotSelectableTargetInfo.newEmptyTargetInfo()
+ assertThat(info.isEmptyTargetInfo).isTrue()
+ assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model.
+ assertThat(info.hasDisplayIcon()).isFalse()
+ assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull()
+ }
+
+ @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper.
+ @Test
+ fun testNewPlaceholderTargetInfo() {
+ val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context)
+ assertThat(info.isPlaceHolderTargetInfo).isTrue()
+ assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model.
+ assertThat(info.hasDisplayIcon()).isTrue()
+ assertThat(info.displayIconHolder.displayIcon)
+ .isInstanceOf(AnimatedVectorDrawable::class.java)
+ // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing
+ // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is
+ // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get
+ // it working myself.
+ }
+
+ @Test
+ fun testNewSelectableTargetInfo() {
+ val resolvedIntent = Intent()
+ val baseDisplayInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ resolvedIntent,
+ createResolveInfo(1, 0, PERSONAL_USER_HANDLE),
+ "label",
+ "extended info",
+ resolvedIntent
+ )
+ val chooserTarget =
+ createChooserTarget(
+ "title",
+ 0.3f,
+ ResolverDataProvider.createComponentName(2),
+ "test_shortcut_id"
+ )
+ val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3)
+ val appTarget =
+ AppTarget(
+ AppTargetId("id"),
+ chooserTarget.componentName.packageName,
+ chooserTarget.componentName.className,
+ UserHandle.CURRENT
+ )
+
+ val targetInfo =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ baseDisplayInfo,
+ mock(),
+ resolvedIntent,
+ chooserTarget,
+ 0.1f,
+ shortcutInfo,
+ appTarget,
+ mock(),
+ )
+ assertThat(targetInfo.isSelectableTargetInfo).isTrue()
+ assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model.
+ assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo)
+ assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName)
+ assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id)
+ assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo)
+ assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget)
+ assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent)
+ // TODO: make more meaningful assertions about the behavior of a selectable target.
+ }
+
+ @Test
+ fun test_SelectableTargetInfo_componentName_no_source_info() {
+ val chooserTarget =
+ createChooserTarget(
+ "title",
+ 0.3f,
+ ResolverDataProvider.createComponentName(1),
+ "test_shortcut_id"
+ )
+ val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3)
+ val appTarget =
+ AppTarget(
+ AppTargetId("id"),
+ chooserTarget.componentName.packageName,
+ chooserTarget.componentName.className,
+ UserHandle.CURRENT
+ )
+ val pkgName = "org.package"
+ val className = "MainActivity"
+ val backupResolveInfo =
+ ResolveInfo().apply {
+ activityInfo =
+ ActivityInfo().apply {
+ packageName = pkgName
+ name = className
+ }
+ }
+
+ val targetInfo =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ null,
+ backupResolveInfo,
+ mock(),
+ chooserTarget,
+ 0.1f,
+ shortcutInfo,
+ appTarget,
+ mock(),
+ )
+ assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className))
+ }
+
+ @Test
+ fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() {
+ val resolvedIntent = Intent("DONT_REFINE_ME")
+ resolvedIntent.putExtra("resolvedIntent", true)
+
+ val baseDisplayInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ resolvedIntent,
+ createResolveInfo(1, 0),
+ "label",
+ "extended info",
+ resolvedIntent
+ )
+ val chooserTarget =
+ createChooserTarget(
+ "title",
+ 0.3f,
+ ResolverDataProvider.createComponentName(2),
+ "test_shortcut_id"
+ )
+ val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3)
+ val appTarget =
+ AppTarget(
+ AppTargetId("id"),
+ chooserTarget.componentName.packageName,
+ chooserTarget.componentName.className,
+ UserHandle.CURRENT
+ )
+
+ val targetInfo =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ baseDisplayInfo,
+ mock(),
+ resolvedIntent,
+ chooserTarget,
+ 0.1f,
+ shortcutInfo,
+ appTarget,
+ mock(),
+ )
+
+ val refinement = Intent("PROPOSED_REFINEMENT")
+ assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
+ }
+
+ @Test
+ fun testNewDisplayResolveInfo() {
+ val intent = Intent(Intent.ACTION_SEND)
+ intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
+ intent.setType("text/plain")
+
+ val resolveInfo = createResolveInfo(3, 0, PERSONAL_USER_HANDLE)
+
+ val targetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfo,
+ "label",
+ "extended info",
+ intent
+ )
+ assertThat(targetInfo.isDisplayResolveInfo).isTrue()
+ assertThat(targetInfo.isMultiDisplayResolveInfo).isFalse()
+ assertThat(targetInfo.isChooserTargetInfo).isFalse()
+ }
+
+ @Test
+ fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() {
+ val originalIntent = Intent("DONT_REFINE_ME")
+ originalIntent.putExtra("originalIntent", true)
+ val mismatchedAlternate = Intent("DOESNT_MATCH")
+ mismatchedAlternate.putExtra("mismatchedAlternate", true)
+ val targetAlternate = Intent("REFINE_ME")
+ targetAlternate.putExtra("targetAlternate", true)
+ val extraMatch = Intent("REFINE_ME")
+ extraMatch.putExtra("extraMatch", true)
+
+ val originalInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ originalIntent,
+ createResolveInfo(3, 0),
+ "label",
+ "extended info",
+ originalIntent
+ )
+ originalInfo.addAlternateSourceIntent(mismatchedAlternate)
+ originalInfo.addAlternateSourceIntent(targetAlternate)
+ originalInfo.addAlternateSourceIntent(extraMatch)
+
+ val refinement = Intent("REFINE_ME") // First match is `targetAlternate`
+ refinement.putExtra("refinement", true)
+
+ val refinedResult = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinement))
+ // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`.
+ assertThat(refinedResult.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue()
+ assertThat(refinedResult.resolvedIntent?.getBooleanExtra("targetAlternate", false)).isTrue()
+ // None of the other source intents got merged in (not even the later one that matched):
+ assertThat(refinedResult.resolvedIntent?.getBooleanExtra("originalIntent", false)).isFalse()
+ assertThat(refinedResult.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false))
+ .isFalse()
+ assertThat(refinedResult.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse()
+ }
+
+ @Test
+ fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() {
+ val originalIntent = Intent("DONT_REFINE_ME")
+ originalIntent.putExtra("originalIntent", true)
+ val mismatchedAlternate = Intent("DOESNT_MATCH")
+ mismatchedAlternate.putExtra("mismatchedAlternate", true)
+
+ val originalInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ originalIntent,
+ createResolveInfo(3, 0),
+ "label",
+ "extended info",
+ originalIntent
+ )
+ originalInfo.addAlternateSourceIntent(mismatchedAlternate)
+
+ val refinement = Intent("PROPOSED_REFINEMENT")
+ assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
+ }
+
+ @Test
+ fun testNewMultiDisplayResolveInfo() {
+ val intent = Intent(Intent.ACTION_SEND)
+ intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
+ intent.setType("text/plain")
+
+ val packageName = "org.pkg.app"
+ val componentA = ComponentName(packageName, "org.pkg.app.ActivityA")
+ val componentB = ComponentName(packageName, "org.pkg.app.ActivityB")
+ val resolveInfoA = createResolveInfo(componentA, 0, PERSONAL_USER_HANDLE)
+ val resolveInfoB = createResolveInfo(componentB, 0, PERSONAL_USER_HANDLE)
+ val firstTargetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfoA,
+ "label 1",
+ "extended info 1",
+ intent
+ )
+ val secondTargetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfoB,
+ "label 2",
+ "extended info 2",
+ intent
+ )
+
+ val multiTargetInfo =
+ MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+ listOf(firstTargetInfo, secondTargetInfo)
+ )
+
+ assertThat(multiTargetInfo.isMultiDisplayResolveInfo).isTrue()
+ assertThat(multiTargetInfo.isDisplayResolveInfo).isTrue() // From legacy inheritance.
+ assertThat(multiTargetInfo.isChooserTargetInfo).isFalse()
+
+ assertThat(multiTargetInfo.extendedInfo).isNull()
+
+ assertThat(multiTargetInfo.allDisplayTargets)
+ .containsExactly(firstTargetInfo, secondTargetInfo)
+
+ assertThat(multiTargetInfo.hasSelected()).isFalse()
+ assertThat(multiTargetInfo.selectedTarget).isNull()
+
+ multiTargetInfo.setSelected(1)
+
+ assertThat(multiTargetInfo.hasSelected()).isTrue()
+ assertThat(multiTargetInfo.selectedTarget).isEqualTo(secondTargetInfo)
+ assertThat(multiTargetInfo.resolvedComponentName).isEqualTo(componentB)
+
+ val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent)
+ assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java)
+ assertThat((refined as MultiDisplayResolveInfo).hasSelected())
+ .isEqualTo(multiTargetInfo.hasSelected())
+
+ // TODO: consider exercising activity-start behavior.
+ // TODO: consider exercising DisplayResolveInfo base class behavior.
+ }
+
+ @Test
+ fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() {
+ val sendImage = Intent("SEND").apply { type = "image/png" }
+ val sendUri = Intent("SEND").apply { type = "text/uri" }
+
+ val resolveInfo = createResolveInfo(1, 0)
+
+ val imageOnlyTarget =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ sendImage,
+ resolveInfo,
+ "Send Image",
+ "Sends only images",
+ sendImage
+ )
+
+ val textOnlyTarget =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ sendUri,
+ resolveInfo,
+ "Send Text",
+ "Sends only text",
+ sendUri
+ )
+
+ val imageOrTextTarget =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ sendImage,
+ resolveInfo,
+ "Send Image or Text",
+ "Sends images or text",
+ sendImage
+ )
+ .apply { addAlternateSourceIntent(sendUri) }
+
+ val multiTargetInfo =
+ MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+ listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget)
+ )
+
+ multiTargetInfo.setSelected(0)
+ assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget)
+ assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents)
+
+ multiTargetInfo.setSelected(1)
+ assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget)
+ assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents)
+
+ multiTargetInfo.setSelected(2)
+ assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget)
+ assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents)
+ }
+
+ @Test
+ fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() {
+ val refined = Intent("SEND")
+ val sendImage = Intent("SEND")
+ val targetOne =
+ spy(
+ DisplayResolveInfo.newDisplayResolveInfo(
+ sendImage,
+ createResolveInfo(1, 0),
+ "Target One",
+ "Target One",
+ sendImage
+ )
+ )
+ val targetTwo =
+ mock<DisplayResolveInfo> { on { tryToCloneWithAppliedRefinement(any()) } doReturn mock }
+
+ val multiTargetInfo =
+ MultiDisplayResolveInfo.newMultiDisplayResolveInfo(listOf(targetOne, targetTwo))
+
+ multiTargetInfo.setSelected(1)
+ assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo)
+
+ multiTargetInfo.tryToCloneWithAppliedRefinement(refined)
+ verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined)
+ verify(targetOne, never()).tryToCloneWithAppliedRefinement(any())
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
index dab1a956..ef0703e6 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -17,62 +17,73 @@
package com.android.intentresolver.contentpreview
import android.content.Intent
-import android.graphics.Bitmap
import android.net.Uri
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.testing.TestLifecycleOwner
+import android.platform.test.flag.junit.CheckFlagsRule
+import android.platform.test.flag.junit.DeviceFlagsValueProvider
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.FakeImageLoader
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
-import com.android.intentresolver.mock
-import com.android.intentresolver.whenever
+import com.android.intentresolver.data.model.ChooserRequest
import com.android.intentresolver.widget.ActionRow
import com.android.intentresolver.widget.ImagePreviewView
import com.google.common.truth.Truth.assertThat
import java.util.function.Consumer
+import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
class ChooserContentPreviewUiTest {
- private val lifecycleOwner = TestLifecycleOwner()
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
private val previewData = mock<PreviewDataProvider>()
private val headlineGenerator = mock<HeadlineGenerator>()
- private val imageLoader =
- object : ImageLoader {
- override fun loadImage(
- callerLifecycle: Lifecycle,
- uri: Uri,
- callback: Consumer<Bitmap?>,
- ) {
- callback.accept(null)
- }
- override fun prePopulate(uris: List<Uri>) = Unit
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null
- }
+ private val imageLoader = FakeImageLoader(emptyMap())
+ private val testMetadataText: CharSequence = "Test metadata text"
private val actionFactory =
object : ActionFactory {
override fun getCopyButtonRunnable(): Runnable? = null
+
override fun getEditButtonRunnable(): Runnable? = null
+
override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+
override fun getModifyShareAction(): ActionRow.Action? = null
+
override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
}
private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>()
+ @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
+
+ private fun createContentPreviewUi(action: String, sharedText: CharSequence? = null) =
+ ChooserContentPreviewUi(
+ testScope,
+ previewData,
+ ChooserRequest(
+ targetIntent = Intent(action),
+ sharedText = sharedText,
+ launchedFromPackage = "org.pkg",
+ ),
+ imageLoader,
+ actionFactory,
+ { null },
+ transitionCallback,
+ headlineGenerator,
+ ContentTypeHint.NONE,
+ testMetadataText,
+ )
@Test
fun test_textPreviewType_useTextPreviewUi() {
whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT)
- val testSubject =
- ChooserContentPreviewUi(
- lifecycleOwner.lifecycle,
- previewData,
- Intent(Intent.ACTION_VIEW),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
+ val testSubject = createContentPreviewUi(action = Intent.ACTION_VIEW)
+
assertThat(testSubject.preferredContentPreview)
.isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java)
@@ -82,16 +93,7 @@ class ChooserContentPreviewUiTest {
@Test
fun test_filePreviewType_useFilePreviewUi() {
whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE)
- val testSubject =
- ChooserContentPreviewUi(
- lifecycleOwner.lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
+ val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND)
assertThat(testSubject.preferredContentPreview)
.isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java)
@@ -107,15 +109,7 @@ class ChooserContentPreviewUiTest {
.thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow())
val testSubject =
- ChooserContentPreviewUi(
- lifecycleOwner.lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") },
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
+ createContentPreviewUi(action = Intent.ACTION_SEND, sharedText = "Shared text")
assertThat(testSubject.mContentPreviewUi)
.isInstanceOf(FilesPlusTextContentPreviewUi::class.java)
verify(previewData, times(1)).imagePreviewFileInfoFlow
@@ -130,20 +124,28 @@ class ChooserContentPreviewUiTest {
whenever(previewData.firstFileInfo)
.thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow())
- val testSubject =
- ChooserContentPreviewUi(
- lifecycleOwner.lifecycle,
- previewData,
- Intent(Intent.ACTION_SEND),
- imageLoader,
- actionFactory,
- transitionCallback,
- headlineGenerator,
- )
+ val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND)
assertThat(testSubject.preferredContentPreview)
.isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java)
verify(previewData, times(1)).imagePreviewFileInfoFlow
verify(transitionCallback, never()).onAllTransitionElementsReady()
}
+
+ @Test
+ fun test_imagePayloadSelectionTypeWithEnabledFlag_usePayloadSelectionPreviewUi() {
+ // Event if we returned wrong type due to a bug, we should not use payload selection UI
+ val uri = Uri.parse("content://org.pkg.app/img.png")
+ whenever(previewData.previewType)
+ .thenReturn(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION)
+ whenever(previewData.uriCount).thenReturn(2)
+ whenever(previewData.firstFileInfo)
+ .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
+ whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow())
+ val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND)
+ assertThat(testSubject.mContentPreviewUi)
+ .isInstanceOf(ShareouselContentPreviewUi::class.java)
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION)
+ }
}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt
index 6db53a9e..6db53a9e 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt
new file mode 100644
index 00000000..0c346095
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.database.MatrixCursor
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.util.Size
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class CursorReadSizeTest {
+ @Test
+ fun missingSizeColumns() {
+ val cursor = MatrixCursor(arrayOf("column")).apply { addRow(arrayOf("abc")) }
+ cursor.moveToFirst()
+
+ assertThat(cursor.readSize()).isNull()
+ }
+
+ @Test
+ fun testIncorrectSizeValues() = runTest {
+ val cursor =
+ MatrixCursor(arrayOf(WIDTH, HEIGHT)).apply {
+ addRow(arrayOf(null, null))
+ addRow(arrayOf("100", null))
+ addRow(arrayOf(null, "100"))
+ addRow(arrayOf("-100", "100"))
+ addRow(arrayOf("100", "-100"))
+ addRow(arrayOf("100", "abc"))
+ addRow(arrayOf("abc", "100"))
+ }
+
+ var i = 0
+ while (cursor.moveToNext()) {
+ i++
+ assertWithMessage("Row $i").that(cursor.readSize()).isNull()
+ }
+ }
+
+ @Test
+ fun testCorrectSizeValues() = runTest {
+ val cursor =
+ MatrixCursor(arrayOf(HEIGHT, WIDTH)).apply {
+ addRow(arrayOf("100", 0))
+ addRow(arrayOf("100", "50"))
+ }
+
+ cursor.moveToNext()
+ assertThat(cursor.readSize()).isEqualTo(Size(0, 100))
+
+ cursor.moveToNext()
+ assertThat(cursor.readSize()).isEqualTo(Size(50, 100))
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt
new file mode 100644
index 00000000..7c50fa42
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.intentresolver.contentpreview
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ActionRow
+import com.google.common.truth.Truth.assertThat
+import java.util.function.Consumer
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+@RunWith(AndroidJUnit4::class)
+class FileContentPreviewUiTest {
+ private val fileCount = 2
+ private val text = "Sharing 2 files"
+ private val testMetadataText: CharSequence = "Test metadata text"
+ private val actionFactory =
+ object : ChooserContentPreviewUi.ActionFactory {
+ override fun getEditButtonRunnable(): Runnable? = null
+ override fun getCopyButtonRunnable(): Runnable? = null
+ override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+ override fun getModifyShareAction(): ActionRow.Action? = null
+ override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
+ }
+ private val headlineGenerator =
+ mock<HeadlineGenerator> { on { getFilesHeadline(fileCount) } doReturn text }
+
+ private val context
+ get() = InstrumentationRegistry.getInstrumentation().context
+
+ private val testSubject =
+ FileContentPreviewUi(
+ fileCount,
+ actionFactory,
+ headlineGenerator,
+ testMetadataText,
+ )
+
+ @Test
+ fun test_display_titleAndMetadataIsDisplayed() {
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ assertThat(headlineRow.findViewById<View>(R.id.headline)).isNull()
+ assertThat(headlineRow.findViewById<View>(R.id.metadata)).isNull()
+
+ val previewView =
+ testSubject.display(
+ context.resources,
+ layoutInflater,
+ gridLayout,
+ headlineRow,
+ )
+
+ assertThat(previewView).isNotNull()
+ val headlineView = headlineRow.findViewById<TextView>(R.id.headline)
+ assertThat(headlineView).isNotNull()
+ assertThat(headlineView?.text).isEqualTo(text)
+ val metadataView = headlineRow.findViewById<TextView>(R.id.metadata)
+ assertThat(metadataView?.text).isEqualTo(testMetadataText)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt
new file mode 100644
index 00000000..a944beee
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt
@@ -0,0 +1,412 @@
+/*
+ * 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.intentresolver.contentpreview
+
+import android.net.Uri
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.CheckBox
+import android.widget.TextView
+import androidx.annotation.IdRes
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ActionRow
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.function.Consumer
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+private const val HEADLINE_IMAGES = "Image Headline"
+private const val HEADLINE_VIDEOS = "Video Headline"
+private const val HEADLINE_FILES = "Files Headline"
+private const val SHARED_TEXT = "Some text to share"
+
+@RunWith(AndroidJUnit4::class)
+class FilesPlusTextContentPreviewUiTest {
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
+ private val actionFactory =
+ object : ChooserContentPreviewUi.ActionFactory {
+ override fun getEditButtonRunnable(): Runnable? = null
+
+ override fun getCopyButtonRunnable(): Runnable? = null
+
+ override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+
+ override fun getModifyShareAction(): ActionRow.Action? = null
+
+ override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
+ }
+ private val imageLoader = mock<ImageLoader>()
+ private val headlineGenerator =
+ mock<HeadlineGenerator> {
+ on { getImagesHeadline(any()) } doReturn HEADLINE_IMAGES
+ on { getVideosHeadline(any()) } doReturn HEADLINE_VIDEOS
+ on { getFilesHeadline(any()) } doReturn HEADLINE_FILES
+ }
+ private val testMetadataText: CharSequence = "Test metadata text"
+
+ private val context
+ get() = getInstrumentation().context
+
+ @Test
+ fun test_displayImagesPlusTextWithoutUriMetadataHeader_showImagesHeadline() {
+ val sharedFileCount = 2
+ val (previewView, headlineRow) = testLoadingHeadline("image/*", sharedFileCount)
+
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
+ verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayVideosPlusTextWithoutUriMetadataHeader_showVideosHeadline() {
+ val sharedFileCount = 2
+ val (previewView, headlineRow) = testLoadingHeadline("video/*", sharedFileCount)
+
+ verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayDocsPlusTextWithoutUriMetadataHeader_showFilesHeadline() {
+ val sharedFileCount = 2
+ val (previewView, headlineRow) = testLoadingHeadline("application/pdf", sharedFileCount)
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayMixedContentPlusTextWithoutUriMetadataHeader_showFilesHeadline() {
+ val sharedFileCount = 2
+ val (previewView, headlineRow) = testLoadingHeadline("*/*", sharedFileCount)
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayImagesPlusTextWithUriMetadataSetHeader_showImagesHeadline() {
+ val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg")
+ val sharedFileCount = loadedFileMetadata.size
+ val (previewView, headlineRow) =
+ testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata)
+
+ verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayVideosPlusTextWithUriMetadataSetHeader_showVideosHeadline() {
+ val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4")
+ val sharedFileCount = loadedFileMetadata.size
+ val (previewView, headlineRow) =
+ testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata)
+
+ verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayImagesAndVideosPlusTextWithUriMetadataSetHeader_showFilesHeadline() {
+ val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4")
+ val sharedFileCount = loadedFileMetadata.size
+ val (previewView, headlineRow) =
+ testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata)
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_displayDocsPlusTextWithUriMetadataSetHeader_showFilesHeadline() {
+ val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf")
+ val sharedFileCount = loadedFileMetadata.size
+ val (previewView, headlineRow) =
+ testLoadingHeadline("application/pdf", sharedFileCount, loadedFileMetadata)
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ verifySharedText(previewView)
+ }
+
+ @Test
+ fun test_uriMetadataIsMoreSpecificThanIntentMimeType_headlineGetsUpdated() {
+ val sharedFileCount = 2
+ val testSubject =
+ FilesPlusTextContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ sharedFileCount,
+ SHARED_TEXT,
+ /*intentMimeType=*/ "*/*",
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ headlineGenerator,
+ testMetadataText,
+ /* allowTextToggle=*/ false,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ )
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount)
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+
+ testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg"))
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
+ verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+
+ @Test
+ fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeHeader_headlineGetsUpdated() {
+ val sharedFileCount = 2
+ val testSubject =
+ FilesPlusTextContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ sharedFileCount,
+ SHARED_TEXT,
+ /*intentMimeType=*/ "*/*",
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ headlineGenerator,
+ testMetadataText,
+ /* allowTextToggle=*/ false,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ assertWithMessage("Headline should not be inflated by default")
+ .that(headlineRow.findViewById<View>(R.id.headline))
+ .isNull()
+ assertWithMessage("Metadata should not be inflated by default")
+ .that(headlineRow.findViewById<View>(R.id.metadata))
+ .isNull()
+
+ val previewView =
+ testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ )
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount)
+ assertWithMessage("Preview parent should not be null").that(previewView).isNotNull()
+ verifyPreviewHeadline(headlineRow, HEADLINE_FILES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+
+ testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg"))
+
+ verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount)
+ verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount)
+ verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+
+ @Test
+ fun test_allowToggle() {
+ val testSubject =
+ FilesPlusTextContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ /* fileCount=*/ 1,
+ SHARED_TEXT,
+ /*intentMimeType=*/ "*/*",
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ headlineGenerator,
+ testMetadataText,
+ /* allowTextToggle=*/ true,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ )
+
+ val checkbox = headlineRow.requireViewById<CheckBox>(R.id.include_text_action)
+ assertThat(checkbox.visibility).isEqualTo(View.VISIBLE)
+ assertThat(checkbox.isChecked).isTrue()
+ }
+
+ @Test
+ fun test_hideTextToggle() {
+ val testSubject =
+ FilesPlusTextContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ /* fileCount=*/ 1,
+ SHARED_TEXT,
+ /*intentMimeType=*/ "*/*",
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ headlineGenerator,
+ testMetadataText,
+ /* allowTextToggle=*/ false,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ )
+
+ val checkbox = headlineRow.requireViewById<CheckBox>(R.id.include_text_action)
+ assertThat(checkbox.visibility).isNotEqualTo(View.VISIBLE)
+ }
+
+ private fun testLoadingHeadline(
+ intentMimeType: String,
+ sharedFileCount: Int,
+ loadedFileMetadata: List<FileInfo>? = null,
+ ): Pair<ViewGroup?, View> {
+ val testSubject =
+ FilesPlusTextContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ sharedFileCount,
+ SHARED_TEXT,
+ intentMimeType,
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ headlineGenerator,
+ testMetadataText,
+ /* allowTextToggle=*/ false,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ assertWithMessage("Headline should not be inflated by default")
+ .that(headlineRow.findViewById<View>(R.id.headline))
+ .isNull()
+
+ assertWithMessage("Metadata should not be inflated by default")
+ .that(headlineRow.findViewById<View>(R.id.metadata))
+ .isNull()
+
+ loadedFileMetadata?.let(testSubject::updatePreviewMetadata)
+ return testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ ) to headlineRow
+ }
+
+ private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List<FileInfo> {
+ val uri = Uri.parse("content://pkg.app/file")
+ return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() }
+ }
+
+ private fun verifyTextViewText(
+ parentView: View?,
+ @IdRes textViewResId: Int,
+ expectedText: CharSequence,
+ ) {
+ assertThat(parentView).isNotNull()
+ val textView = parentView?.findViewById<TextView>(textViewResId)
+ assertThat(textView).isNotNull()
+ assertThat(textView?.text).isEqualTo(expectedText)
+ }
+
+ private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) {
+ verifyTextViewText(headerViewParent, R.id.headline, expectedText)
+ }
+
+ private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) {
+ verifyTextViewText(headerViewParent, R.id.metadata, expectedText)
+ }
+
+ private fun verifySharedText(previewView: ViewGroup?) {
+ verifyTextViewText(previewView, R.id.content_preview_text, SHARED_TEXT)
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt
index a65280e5..dbc37b44 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt
@@ -18,44 +18,73 @@ package com.android.intentresolver.contentpreview
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
-import com.google.common.truth.Truth.assertThat
@RunWith(AndroidJUnit4::class)
class HeadlineGeneratorImplTest {
- @Test
- fun testHeadlineGeneration() {
- val generator = HeadlineGeneratorImpl(
- InstrumentationRegistry.getInstrumentation().getTargetContext())
- val str = "Some string"
- val url = "http://www.google.com"
+ private val generator =
+ HeadlineGeneratorImpl(InstrumentationRegistry.getInstrumentation().targetContext)
+ private val str = "Some string"
+ private val url = "http://www.google.com"
+ @Test
+ fun testTextHeadline() {
assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text")
assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link")
+ }
+ @Test
+ fun testImagesWIthTextHeadline() {
assertThat(generator.getImagesWithTextHeadline(str, 1)).isEqualTo("Sharing image with text")
assertThat(generator.getImagesWithTextHeadline(url, 1)).isEqualTo("Sharing image with link")
- assertThat(generator.getImagesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 images with text")
- assertThat(generator.getImagesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 images with link")
+ assertThat(generator.getImagesWithTextHeadline(str, 5))
+ .isEqualTo("Sharing 5 images with text")
+ assertThat(generator.getImagesWithTextHeadline(url, 5))
+ .isEqualTo("Sharing 5 images with link")
+ }
+ @Test
+ fun testVideosWithTextHeadline() {
assertThat(generator.getVideosWithTextHeadline(str, 1)).isEqualTo("Sharing video with text")
assertThat(generator.getVideosWithTextHeadline(url, 1)).isEqualTo("Sharing video with link")
- assertThat(generator.getVideosWithTextHeadline(str, 5)).isEqualTo("Sharing 5 videos with text")
- assertThat(generator.getVideosWithTextHeadline(url, 5)).isEqualTo("Sharing 5 videos with link")
+ assertThat(generator.getVideosWithTextHeadline(str, 5))
+ .isEqualTo("Sharing 5 videos with text")
+ assertThat(generator.getVideosWithTextHeadline(url, 5))
+ .isEqualTo("Sharing 5 videos with link")
+ }
+ @Test
+ fun testFilesWithTextHeadline() {
assertThat(generator.getFilesWithTextHeadline(str, 1)).isEqualTo("Sharing file with text")
assertThat(generator.getFilesWithTextHeadline(url, 1)).isEqualTo("Sharing file with link")
- assertThat(generator.getFilesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 files with text")
- assertThat(generator.getFilesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 files with link")
+ assertThat(generator.getFilesWithTextHeadline(str, 5))
+ .isEqualTo("Sharing 5 files with text")
+ assertThat(generator.getFilesWithTextHeadline(url, 5))
+ .isEqualTo("Sharing 5 files with link")
+ }
+ @Test
+ fun testImagesHeadline() {
assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image")
assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images")
+ }
+ @Test
+ fun testVideosHeadline() {
assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video")
assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos")
+ }
+ @Test
+ fun testFilesHeadline() {
assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file")
assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files")
}
+
+ @Test
+ fun testAlbumHeadline() {
+ assertThat(generator.getAlbumHeadline()).isEqualTo("Sharing album")
+ }
}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
new file mode 100644
index 00000000..9884a675
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
@@ -0,0 +1,560 @@
+/*
+ * 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.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.content.Intent
+import android.database.MatrixCursor
+import android.media.MediaMetadata
+import android.net.Uri
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.FlagsParameterization
+import android.platform.test.flag.junit.SetFlagsRule
+import android.provider.DocumentsContract
+import android.provider.Downloads
+import android.provider.OpenableColumns
+import com.android.intentresolver.Flags.FLAG_INDIVIDUAL_METADATA_TITLE_READ
+import com.google.common.truth.Truth.assertThat
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(Parameterized::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class PreviewDataProviderTest(flags: FlagsParameterization) {
+ private val contentResolver = mock<ContentInterface>()
+ private val mimeTypeClassifier = DefaultMimeTypeClassifier
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
+ @get:Rule val setFlagsRule = SetFlagsRule(flags)
+
+ private fun createDataProvider(
+ targetIntent: Intent,
+ scope: CoroutineScope = testScope,
+ additionalContentUri: Uri? = null,
+ resolver: ContentInterface = contentResolver,
+ typeClassifier: MimeTypeClassifier = mimeTypeClassifier,
+ ) = PreviewDataProvider(scope, targetIntent, additionalContentUri, resolver, typeClassifier)
+
+ @Test
+ fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() {
+ val targetIntent = Intent(Intent.ACTION_VIEW)
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+ verify(contentResolver, never()).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val fileName = "notes.txt"
+ val uri = Uri.parse("content://org.pkg.app/$fileName")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_STREAM, uri)
+ type = "text/plain"
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("text/plain")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.getFirstFileName()).isEqualTo(fileName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleTextFileWithDisplayNameAndTitle_displayNameTakesPrecedenceOverTitle() =
+ testScope.runTest {
+ val uri = Uri.parse("content://org.pkg.app/1234")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_STREAM, uri)
+ type = "text/plain"
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("text/plain")
+ val title = "Notes"
+ val displayName = "Notes.txt"
+ whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenReturn(
+ MatrixCursor(arrayOf(Downloads.Impl.COLUMN_TITLE, OpenableColumns.DISPLAY_NAME))
+ .apply { addRow(arrayOf(title, displayName)) }
+ )
+ contentResolver.setTitle(uri, title)
+ contentResolver.setDisplayName(uri, displayName)
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.getFirstFileName()).isEqualTo(displayName)
+ }
+
+ @Test
+ fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() {
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" }
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+ verify(contentResolver, never()).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleImage_resolvesToImagePreviewUi() {
+ val uri = Uri.parse("content://org.pkg.app/image.png")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("image/png")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleFile_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val fileName = "paper.pdf"
+ val uri = Uri.parse("content://org.pkg.app/$fileName")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(fileName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val fileName = "image.png"
+ val uri = Uri.parse("content://org.pkg.app/$fileName")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure"))
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(fileName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleFileWithFailingMetadata_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val fileName = "manual.pdf"
+ val uri = Uri.parse("content://org.pkg.app/$fileName")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "application/pdf"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ whenever(contentResolver.getStreamTypes(uri, "*/*"))
+ .thenThrow(SecurityException("test failure"))
+ whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenThrow(SecurityException("test failure"))
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(fileName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ @EnableFlags(FLAG_INDIVIDUAL_METADATA_TITLE_READ)
+ fun test_sendSingleImageWithFailingGetTypeDisjointTitleRead_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val uri = Uri.parse("content://org.pkg.app/image.png")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure"))
+ val title = "Image Title"
+ contentResolver.setTitle(uri, title)
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(title)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_sendSingleFileWithFailingImageMetadata_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val fileName = "notes.pdf"
+ val uri = Uri.parse("content://org.pkg.app/$fileName")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "application/pdf"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ whenever(contentResolver.getStreamTypes(uri, "*/*"))
+ .thenThrow(SecurityException("test failure"))
+ whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenThrow(SecurityException("test failure"))
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(fileName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ @EnableFlags(FLAG_INDIVIDUAL_METADATA_TITLE_READ)
+ fun test_sendSingleFileWithFailingImageMetadataIndividualTitleRead_resolvesToFilePreviewUi() =
+ testScope.runTest {
+ val uri = Uri.parse("content://org.pkg.app/image.png")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getStreamTypes(uri, "*/*"))
+ .thenThrow(SecurityException("test failure"))
+ whenever(contentResolver.query(uri, ICON_METADATA_COLUMNS, null, null))
+ .thenThrow(SecurityException("test failure"))
+ val displayName = "display name"
+ contentResolver.setDisplayName(uri, displayName)
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(displayName)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_SingleFileUriWithImageTypeInGetStreamTypes_useImagePreviewUi() {
+ val uri = Uri.parse("content://org.pkg.app/paper.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getStreamTypes(uri, "*/*"))
+ .thenReturn(arrayOf("application/pdf", "image/png"))
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() {
+ testMetadataToImagePreview(
+ columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
+ values =
+ arrayOf(
+ DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or
+ DocumentsContract.Document.FLAG_SUPPORTS_METADATA
+ ),
+ )
+ }
+
+ @Test
+ fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() {
+ testMetadataToImagePreview(
+ columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
+ values = arrayOf("content://org.pkg.app/test.pdf?thumbnail"),
+ )
+ }
+
+ private fun testMetadataToImagePreview(columns: Array<String>, values: Array<Any>) {
+ val uri = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ val cursor = MatrixCursor(columns).apply { addRow(values) }
+ whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenReturn(cursor)
+
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNotNull()
+ verify(contentResolver, times(1)).getType(any())
+ assertThat(cursor.isClosed).isTrue()
+ }
+
+ @Test
+ fun test_emptyQueryResult_cursorGetsClosed() {
+ val uri = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ val cursor = MatrixCursor(emptyArray())
+ whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenReturn(cursor)
+
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ verify(contentResolver, times(1)).query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())
+ assertThat(cursor.isClosed).isTrue()
+ }
+
+ @Test
+ fun test_multipleImageUri_useImagePreviewUi() {
+ val uri1 = Uri.parse("content://org.pkg.app/test.png")
+ val uri2 = Uri.parse("content://org.pkg.app/test.jpg")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ },
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("image/png")
+ whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(2)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
+ // preview type can be determined by the first URI type
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_SomeImageUri_useImagePreviewUi() {
+ val uri1 = Uri.parse("content://org.pkg.app/test.png")
+ val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
+ whenever(contentResolver.getType(uri1)).thenReturn("image/png")
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ },
+ )
+ }
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(2)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
+ // preview type can be determined by the first URI type
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_someFileUrisWithPreview_useImagePreviewUi() {
+ val uri1 = Uri.parse("content://org.pkg.app/test.mp4")
+ val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ },
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4")
+ whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png"))
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(2)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1)
+ verify(contentResolver, times(2)).getType(any())
+ }
+
+ @Test
+ fun test_allFileUrisWithoutPreview_useFilePreviewUi() =
+ testScope.runTest {
+ val firstFileName = "test.html"
+ val uri1 = Uri.parse("content://org.pkg.app/$firstFileName")
+ val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ },
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("text/html")
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ val testSubject = createDataProvider(targetIntent)
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ assertThat(testSubject.uriCount).isEqualTo(2)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1)
+ assertThat(testSubject.firstFileInfo?.previewUri).isNull()
+ assertThat(testSubject.getFirstFileName()).isEqualTo(firstFileName)
+ verify(contentResolver, times(2)).getType(any())
+ }
+
+ @Test
+ fun test_imagePreviewFileInfoFlow_dataLoadedOnce() =
+ testScope.runTest {
+ val uri1 = Uri.parse("content://org.pkg.app/test.html")
+ val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ },
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("text/html")
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ whenever(contentResolver.getStreamTypes(uri1, "*/*"))
+ .thenReturn(arrayOf("text/html", "image/jpeg"))
+ whenever(contentResolver.getStreamTypes(uri2, "*/*"))
+ .thenReturn(arrayOf("application/pdf", "image/png"))
+ val testSubject = createDataProvider(targetIntent)
+
+ val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList()
+ val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList()
+
+ assertThat(fileInfoListOne).hasSize(2)
+ assertThat(fileInfoListOne).containsAtLeastElementsIn(fileInfoListTwo).inOrder()
+
+ verify(contentResolver, times(1)).getType(uri1)
+ verify(contentResolver, times(1)).getStreamTypes(uri1, "*/*")
+ verify(contentResolver, times(1)).getType(uri2)
+ verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*")
+ }
+
+ @Test
+ fun sendImageWithAdditionalContentUri_showPayloadTogglingUi() {
+ val uri = Uri.parse("content://org.pkg.app/image.png")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("image/png")
+ val testSubject =
+ createDataProvider(
+ targetIntent,
+ additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"),
+ )
+
+ assertThat(testSubject.previewType)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun sendItemsWithAdditionalContentUriWithSameAuthority_showImagePreviewUi() {
+ val uri = Uri.parse("content://org.pkg.app/image.png")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
+ whenever(contentResolver.getType(uri)).thenReturn("image/png")
+ val testSubject =
+ createDataProvider(
+ targetIntent,
+ additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"),
+ )
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ assertThat(testSubject.uriCount).isEqualTo(1)
+ assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri)
+ assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri)
+ verify(contentResolver, times(1)).getType(any())
+ }
+
+ @Test
+ fun test_nonSendIntentActionWithAdditionalContentUri_resolvesToTextPreviewUiSynchronously() {
+ val targetIntent = Intent(Intent.ACTION_VIEW)
+ val testSubject =
+ createDataProvider(
+ targetIntent,
+ additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"),
+ )
+
+ assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+ verify(contentResolver, never()).getType(any())
+ }
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun parameters(): List<FlagsParameterization> =
+ FlagsParameterization.allCombinationsOf(FLAG_INDIVIDUAL_METADATA_TITLE_READ)
+ }
+}
+
+private fun ContentInterface.setDisplayName(uri: Uri, displayName: String) =
+ setMetadata(uri, arrayOf(OpenableColumns.DISPLAY_NAME), arrayOf(displayName))
+
+private fun ContentInterface.setTitle(uri: Uri, title: String) =
+ setMetadata(uri, arrayOf(Downloads.Impl.COLUMN_TITLE), arrayOf(title))
+
+private fun ContentInterface.setMetadata(uri: Uri, columns: Array<String>, values: Array<String>) {
+ whenever(query(uri, columns, null, null))
+ .thenReturn(MatrixCursor(columns).apply { addRow(values) })
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt
new file mode 100644
index 00000000..8c810058
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt
@@ -0,0 +1,496 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Size
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PreviewImageLoaderTest {
+ private val scope = TestScope()
+
+ @Test
+ fun test_cachingImageRequest_imageCached() =
+ scope.runTest {
+ val uri = createUri(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val b1 = testSubject.invoke(uri, Size(200, 100))
+ val b2 = testSubject.invoke(uri, Size(200, 100), caching = false)
+ assertThat(b1).isEqualTo(b2)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(1)
+ }
+
+ @Test
+ fun test_nonCachingImageRequest_imageNotCached() =
+ scope.runTest {
+ val uri = createUri(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ testSubject.invoke(uri, Size(200, 100), caching = false)
+ testSubject.invoke(uri, Size(200, 100), caching = false)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(2)
+ }
+
+ @Test
+ fun test_twoSimultaneousImageRequests_requestsDeduplicated() =
+ scope.runTest {
+ val uri = createUri(0)
+ val loadingStartedDeferred = CompletableDeferred<Unit>()
+ val bitmapDeferred = CompletableDeferred<Bitmap>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = {
+ loadingStartedDeferred.complete(Unit)
+ bitmapDeferred.await()
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val b1Deferred = async { testSubject.invoke(uri, Size(200, 100), caching = false) }
+ loadingStartedDeferred.await()
+ val b2Deferred =
+ async(start = CoroutineStart.UNDISPATCHED) {
+ testSubject.invoke(uri, Size(200, 100), caching = true)
+ }
+ bitmapDeferred.complete(createBitmap(200, 200))
+
+ val b1 = b1Deferred.await()
+ val b2 = b2Deferred.await()
+ assertThat(b1).isEqualTo(b2)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(1)
+ }
+
+ @Test
+ fun test_cachingRequestCancelledAndEvoked_imageLoadingCancelled() =
+ scope.runTest {
+ val uriOne = createUri(1)
+ val uriTwo = createUri(2)
+ val loadingStartedDeferred = CompletableDeferred<Unit>()
+ val cancelledRequests = mutableSetOf<Uri>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uriOne] = {
+ loadingStartedDeferred.complete(Unit)
+ try {
+ awaitCancellation()
+ } catch (e: CancellationException) {
+ cancelledRequests.add(uriOne)
+ throw e
+ }
+ }
+ fakeInvoke[uriTwo] = { createBitmap(200, 200) }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ cacheSize = 1,
+ defaultPreviewSize = 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val jobOne = launch { testSubject.invoke(uriOne, Size(200, 100)) }
+ loadingStartedDeferred.await()
+ jobOne.cancel()
+ scope.runCurrent()
+
+ assertThat(cancelledRequests).isEmpty()
+
+ // second URI should evict the first item from the cache
+ testSubject.invoke(uriTwo, Size(200, 100))
+
+ assertThat(thumbnailLoader.invokeCalls).hasSize(2)
+ assertThat(cancelledRequests).containsExactly(uriOne)
+ }
+
+ @Test
+ fun test_nonCachingRequestClientCancels_imageLoadingCancelled() =
+ scope.runTest {
+ val uri = createUri(1)
+ val loadingStartedDeferred = CompletableDeferred<Unit>()
+ val cancelledRequests = mutableSetOf<Uri>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = {
+ loadingStartedDeferred.complete(Unit)
+ try {
+ awaitCancellation()
+ } catch (e: CancellationException) {
+ cancelledRequests.add(uri)
+ throw e
+ }
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ cacheSize = 1,
+ defaultPreviewSize = 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val job = launch { testSubject.invoke(uri, Size(200, 100), caching = false) }
+ loadingStartedDeferred.await()
+ job.cancel()
+ scope.runCurrent()
+
+ assertThat(cancelledRequests).containsExactly(uri)
+ }
+
+ @Test
+ fun test_requestHigherResImage_newImageLoaded() =
+ scope.runTest {
+ val uri = createUri(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val b1 = testSubject.invoke(uri, Size(100, 100))
+ val b2 = testSubject.invoke(uri, Size(200, 200))
+ assertThat(b1).isNotNull()
+ assertThat(b1!!.width).isEqualTo(100)
+ assertThat(b2).isNotNull()
+ assertThat(b2!!.width).isEqualTo(200)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(2)
+ }
+
+ @Test
+ fun test_imageLoadingThrowsException_returnsNull() =
+ scope.runTest {
+ val uri = createUri(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { throw SecurityException("test") }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val bitmap = testSubject.invoke(uri, Size(100, 100))
+ assertThat(bitmap).isNull()
+ }
+
+ @Test
+ fun test_requestHigherResImage_cancelsLowerResLoading() =
+ scope.runTest {
+ val uri = createUri(0)
+ val cancelledRequestCount = AtomicInteger(0)
+ val imageLoadingStarted = CompletableDeferred<Unit>()
+ val bitmapDeferred = CompletableDeferred<Bitmap>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = {
+ imageLoadingStarted.complete(Unit)
+ try {
+ bitmapDeferred.await()
+ } catch (e: CancellationException) {
+ cancelledRequestCount.getAndIncrement()
+ throw e
+ }
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val lowResSize = 100
+ val highResSize = 200
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ testSubject.invoke(uri, Size(lowResSize, lowResSize))
+ }
+ imageLoadingStarted.await()
+ val result = async { testSubject.invoke(uri, Size(highResSize, highResSize)) }
+ runCurrent()
+ assertThat(cancelledRequestCount.get()).isEqualTo(1)
+
+ bitmapDeferred.complete(createBitmap(highResSize, highResSize))
+ val bitmap = result.await()
+ assertThat(bitmap).isNotNull()
+ assertThat(bitmap!!.width).isEqualTo(highResSize)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(2)
+ }
+
+ @Test
+ fun test_requestLowerResImage_cachedHigherResImageReturned() =
+ scope.runTest {
+ val uri = createUri(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
+ }
+ val lowResSize = 100
+ val highResSize = 200
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val b1 = testSubject.invoke(uri, Size(highResSize, highResSize))
+ val b2 = testSubject.invoke(uri, Size(lowResSize, lowResSize))
+ assertThat(b1).isEqualTo(b2)
+ assertThat(b2!!.width).isEqualTo(highResSize)
+ assertThat(thumbnailLoader.invokeCalls).hasSize(1)
+ }
+
+ @Test
+ fun test_incorrectSizeRequested_defaultSizeIsUsed() =
+ scope.runTest {
+ val uri = createUri(0)
+ val defaultPreviewSize = 100
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ cacheSize = 1,
+ defaultPreviewSize,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ val b1 = testSubject(uri, Size(0, 0))
+ assertThat(b1!!.width).isEqualTo(defaultPreviewSize)
+
+ val largerImageSize = 200
+ val b2 = testSubject(uri, Size(largerImageSize, largerImageSize))
+ assertThat(b2!!.width).isEqualTo(largerImageSize)
+ }
+
+ @Test
+ fun test_prePopulateImages_cachesImagesUpToTheCacheSize() =
+ scope.runTest {
+ val previewSize = Size(100, 100)
+ val uris = List(2) { createUri(it) }
+ val loadingCount = AtomicInteger(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ for (uri in uris) {
+ fakeInvoke[uri] = { size ->
+ loadingCount.getAndIncrement()
+ createBitmap(size.width, size.height)
+ }
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ testSubject.prePopulate(uris.map { it to previewSize })
+ runCurrent()
+
+ assertThat(loadingCount.get()).isEqualTo(1)
+ assertThat(thumbnailLoader.invokeCalls).containsExactly(uris[0])
+
+ testSubject(uris[0], previewSize)
+ runCurrent()
+
+ assertThat(loadingCount.get()).isEqualTo(1)
+ }
+
+ @Test
+ fun test_oldRecordEvictedFromTheCache() =
+ scope.runTest {
+ val previewSize = Size(100, 100)
+ val uriOne = createUri(1)
+ val uriTwo = createUri(2)
+ val requestsPerUri = HashMap<Uri, AtomicInteger>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ for (uri in arrayOf(uriOne, uriTwo)) {
+ fakeInvoke[uri] = { size ->
+ requestsPerUri.getOrPut(uri) { AtomicInteger() }.incrementAndGet()
+ createBitmap(size.width, size.height)
+ }
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ testSubject(uriOne, previewSize)
+ testSubject(uriTwo, previewSize)
+ testSubject(uriTwo, previewSize)
+ testSubject(uriOne, previewSize)
+
+ assertThat(requestsPerUri[uriOne]?.get()).isEqualTo(2)
+ assertThat(requestsPerUri[uriTwo]?.get()).isEqualTo(1)
+ }
+
+ @Test
+ fun test_doNotCacheNulls() =
+ scope.runTest {
+ val previewSize = Size(100, 100)
+ val uri = createUri(1)
+ val loadingCount = AtomicInteger(0)
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = {
+ loadingCount.getAndIncrement()
+ null
+ }
+ }
+ val testSubject =
+ PreviewImageLoader(
+ backgroundScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ testSubject(uri, previewSize)
+ testSubject(uri, previewSize)
+
+ assertThat(loadingCount.get()).isEqualTo(2)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun invoke_onClosedImageLoaderScope_throwsCancellationException() =
+ scope.runTest {
+ val uri = createUri(1)
+ val thumbnailLoader = FakeThumbnailLoader().apply { fakeInvoke[uri] = { null } }
+ val imageLoaderScope = CoroutineScope(coroutineContext)
+ val testSubject =
+ PreviewImageLoader(
+ imageLoaderScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+ imageLoaderScope.cancel()
+ testSubject(uri, Size(200, 200))
+ }
+
+ @Test(expected = CancellationException::class)
+ fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() =
+ scope.runTest {
+ val uri = createUri(1)
+ val loadingStarted = CompletableDeferred<Unit>()
+ val bitmapDeferred = CompletableDeferred<Bitmap?>()
+ val thumbnailLoader =
+ FakeThumbnailLoader().apply {
+ fakeInvoke[uri] = {
+ loadingStarted.complete(Unit)
+ bitmapDeferred.await()
+ }
+ }
+ val imageLoaderScope = CoroutineScope(coroutineContext)
+ val testSubject =
+ PreviewImageLoader(
+ imageLoaderScope,
+ 1,
+ 100,
+ thumbnailLoader,
+ StandardTestDispatcher(scope.testScheduler),
+ )
+
+ launch {
+ loadingStarted.await()
+ imageLoaderScope.cancel()
+ }
+ testSubject(uri, Size(200, 200))
+ }
+}
+
+private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png")
+
+private fun createBitmap(width: Int, height: Int) =
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt
new file mode 100644
index 00000000..60159160
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.intentresolver.contentpreview
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ActionRow
+import com.google.common.truth.Truth.assertThat
+import java.util.function.Consumer
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+@RunWith(AndroidJUnit4::class)
+class TextContentPreviewUiTest {
+ private val text = "Shared Text"
+ private val title = "Preview Title"
+ private val albumHeadline = "Album headline"
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
+ private val actionFactory =
+ object : ChooserContentPreviewUi.ActionFactory {
+ override fun getEditButtonRunnable(): Runnable? = null
+
+ override fun getCopyButtonRunnable(): Runnable? = null
+
+ override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+
+ override fun getModifyShareAction(): ActionRow.Action? = null
+
+ override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
+ }
+ private val imageLoader = mock<ImageLoader>()
+ private val headlineGenerator =
+ mock<HeadlineGenerator> {
+ on { getTextHeadline(text) } doReturn text
+ on { getAlbumHeadline() } doReturn albumHeadline
+ }
+ private val testMetadataText: CharSequence = "Test metadata text"
+
+ private val context
+ get() = InstrumentationRegistry.getInstrumentation().context
+
+ private val testSubject =
+ TextContentPreviewUi(
+ testScope,
+ text,
+ title,
+ testMetadataText,
+ /*previewThumbnail=*/ null,
+ actionFactory,
+ imageLoader,
+ headlineGenerator,
+ ContentTypeHint.NONE,
+ )
+
+ @Test
+ fun test_display_headlineIsDisplayed() {
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ val previewView =
+ testSubject.display(
+ context.resources,
+ layoutInflater,
+ gridLayout,
+ headlineRow,
+ )
+
+ assertThat(previewView).isNotNull()
+ val headlineView = headlineRow.findViewById<TextView>(R.id.headline)
+ assertThat(headlineView).isNotNull()
+ assertThat(headlineView?.text).isEqualTo(text)
+ val metadataView = headlineRow.findViewById<TextView>(R.id.metadata)
+ assertThat(metadataView).isNotNull()
+ assertThat(metadataView?.text).isEqualTo(testMetadataText)
+ }
+
+ @Test
+ fun test_display_albumHeadlineOverride() {
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ val albumSubject =
+ TextContentPreviewUi(
+ testScope,
+ text,
+ title,
+ testMetadataText,
+ /*previewThumbnail=*/ null,
+ actionFactory,
+ imageLoader,
+ headlineGenerator,
+ ContentTypeHint.ALBUM,
+ )
+
+ val previewView =
+ albumSubject.display(
+ context.resources,
+ layoutInflater,
+ gridLayout,
+ headlineRow,
+ )
+
+ assertThat(previewView).isNotNull()
+ val headlineView = headlineRow.findViewById<TextView>(R.id.headline)
+ assertThat(headlineView).isNotNull()
+ assertThat(headlineView?.text).isEqualTo(albumHeadline)
+ val metadataView = headlineRow.findViewById<TextView>(R.id.metadata)
+ assertThat(metadataView).isNotNull()
+ assertThat(metadataView?.text).isEqualTo(testMetadataText)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
new file mode 100644
index 00000000..21eb12ea
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
@@ -0,0 +1,227 @@
+/*
+ * 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.intentresolver.contentpreview
+
+import android.net.Uri
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.annotation.IdRes
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+private const val IMAGE_HEADLINE = "Image Headline"
+private const val VIDEO_HEADLINE = "Video Headline"
+private const val FILES_HEADLINE = "Files Headline"
+
+@RunWith(AndroidJUnit4::class)
+class UnifiedContentPreviewUiTest {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
+ private val actionFactory =
+ mock<ChooserContentPreviewUi.ActionFactory> {
+ on { createCustomActions() } doReturn emptyList()
+ }
+ private val imageLoader = mock<ImageLoader>()
+ private val headlineGenerator =
+ mock<HeadlineGenerator> {
+ on { getImagesHeadline(any()) } doReturn IMAGE_HEADLINE
+ on { getVideosHeadline(any()) } doReturn VIDEO_HEADLINE
+ on { getFilesHeadline(any()) } doReturn FILES_HEADLINE
+ }
+ private val testMetadataText: CharSequence = "Test metadata text"
+
+ private val context
+ get() = getInstrumentation().context
+
+ @Test
+ fun test_displayImagesWithoutUriMetadataHeader_showImagesHeadline() {
+ testLoadingHeadline("image/*", files = null) { headlineRow ->
+ verify(headlineGenerator, times(1)).getImagesHeadline(2)
+ verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+ }
+
+ @Test
+ fun test_displayVideosWithoutUriMetadataHeader_showImagesHeadline() {
+ testLoadingHeadline("video/*", files = null) { headlineRow ->
+ verify(headlineGenerator, times(1)).getVideosHeadline(2)
+ verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+ }
+
+ @Test
+ fun test_displayDocumentsWithoutUriMetadataHeader_showImagesHeadline() {
+ testLoadingHeadline("application/pdf", files = null) { headlineRow ->
+ verify(headlineGenerator, times(1)).getFilesHeadline(2)
+ verifyPreviewHeadline(headlineRow, FILES_HEADLINE)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+ }
+
+ @Test
+ fun test_displayMixedContentWithoutUriMetadataHeader_showImagesHeadline() {
+ testLoadingHeadline("*/*", files = null) { headlineRow ->
+ verify(headlineGenerator, times(1)).getFilesHeadline(2)
+ verifyPreviewHeadline(headlineRow, FILES_HEADLINE)
+ verifyPreviewMetadata(headlineRow, testMetadataText)
+ }
+ }
+
+ @Test
+ fun test_displayImagesWithUriMetadataSetHeader_showImagesHeadline() {
+ val uri = Uri.parse("content://pkg.app/image.png")
+ val files =
+ listOf(
+ FileInfo.Builder(uri).withMimeType("image/png").build(),
+ FileInfo.Builder(uri).withMimeType("image/jpeg").build(),
+ )
+ testLoadingHeadline("image/*", files) { headlineRow ->
+ verify(headlineGenerator, times(1)).getImagesHeadline(2)
+ verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE)
+ }
+ }
+
+ @Test
+ fun test_displayVideosWithUriMetadataSetHeader_showImagesHeadline() {
+ val uri = Uri.parse("content://pkg.app/image.png")
+ val files =
+ listOf(
+ FileInfo.Builder(uri).withMimeType("video/mp4").build(),
+ FileInfo.Builder(uri).withMimeType("video/mp4").build(),
+ )
+ testLoadingHeadline("video/*", files) { headlineRow ->
+ verify(headlineGenerator, times(1)).getVideosHeadline(2)
+ verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE)
+ }
+ }
+
+ @Test
+ fun test_displayImagesAndVideosWithUriMetadataSetHeader_showImagesHeadline() {
+ val uri = Uri.parse("content://pkg.app/image.png")
+ val files =
+ listOf(
+ FileInfo.Builder(uri).withMimeType("image/png").build(),
+ FileInfo.Builder(uri).withMimeType("video/mp4").build(),
+ )
+ testLoadingHeadline("*/*", files) { headlineRow ->
+ verify(headlineGenerator, times(1)).getFilesHeadline(2)
+ verifyPreviewHeadline(headlineRow, FILES_HEADLINE)
+ }
+ }
+
+ @Test
+ fun test_displayDocumentsWithUriMetadataSetHeader_showImagesHeadline() {
+ val uri = Uri.parse("content://pkg.app/image.png")
+ val files =
+ listOf(
+ FileInfo.Builder(uri).withMimeType("application/pdf").build(),
+ FileInfo.Builder(uri).withMimeType("application/pdf").build(),
+ )
+ testLoadingHeadline("application/pdf", files) { headlineRow ->
+ verify(headlineGenerator, times(1)).getFilesHeadline(2)
+ verifyPreviewHeadline(headlineRow, FILES_HEADLINE)
+ }
+ }
+
+ private fun testLoadingHeadline(
+ intentMimeType: String,
+ files: List<FileInfo>?,
+ verificationBlock: (View?) -> Unit,
+ ) {
+ testScope.runTest {
+ val endMarker = FileInfo.Builder(Uri.EMPTY).build()
+ val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1)
+ val testSubject =
+ UnifiedContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ intentMimeType,
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ object : TransitionElementStatusCallback {
+ override fun onTransitionElementReady(name: String) = Unit
+ override fun onAllTransitionElementsReady() = Unit
+ },
+ files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker },
+ /*itemCount=*/ 2,
+ headlineGenerator,
+ testMetadataText,
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout =
+ layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false)
+ as ViewGroup
+ val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container)
+
+ assertWithMessage("Headline row should not be inflated by default")
+ .that(headlineRow.findViewById<View>(R.id.headline))
+ .isNull()
+
+ testSubject.display(
+ context.resources,
+ LayoutInflater.from(context),
+ gridLayout,
+ headlineRow,
+ )
+ emptySourceFlow.tryEmit(endMarker)
+ verificationBlock(headlineRow)
+ }
+ }
+
+ private fun verifyTextViewText(
+ viewParent: View?,
+ @IdRes textViewResId: Int,
+ expectedText: CharSequence,
+ ) {
+ assertThat(viewParent).isNotNull()
+ val textView = viewParent?.findViewById<TextView>(textViewResId)
+ assertThat(textView).isNotNull()
+ assertThat(textView?.text).isEqualTo(expectedText)
+ }
+
+ private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) {
+ verifyTextViewText(headerViewParent, R.id.headline, expectedText)
+ }
+
+ private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) {
+ verifyTextViewText(headerViewParent, R.id.metadata, expectedText)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt
new file mode 100644
index 00000000..74f1e59d
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.database.MatrixCursor
+import android.media.MediaMetadata
+import android.net.Uri
+import android.provider.DocumentsContract
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+class UriMetadataReaderTest {
+ private val uri = Uri.parse("content://org.pkg.app/item")
+ private val contentResolver = mock<ContentInterface>()
+
+ @Test
+ fun testImageUri() {
+ val mimeType = "image/png"
+ whenever(contentResolver.getType(uri)).thenReturn(mimeType)
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
+
+ testSubject.getMetadata(uri).let { fileInfo ->
+ assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
+ assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType)
+ assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri)
+ }
+ }
+
+ @Test
+ fun testFileUriWithImageTypeSupport() {
+ val mimeType = "application/pdf"
+ val imageType = "image/png"
+ whenever(contentResolver.getType(uri)).thenReturn(mimeType)
+ whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType))
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
+
+ testSubject.getMetadata(uri).let { fileInfo ->
+ assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
+ assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType)
+ assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri)
+ }
+ }
+
+ @Test
+ fun testFileUriWithThumbnailSupport() {
+ val mimeType = "application/pdf"
+ whenever(contentResolver.getType(uri)).thenReturn(mimeType)
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS)
+ whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull()))
+ .thenReturn(
+ MatrixCursor(columns).apply {
+ addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL))
+ }
+ )
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
+
+ testSubject.getMetadata(uri).let { fileInfo ->
+ assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
+ assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType)
+ assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri)
+ }
+ }
+
+ @Test
+ fun testFileUriWithPreviewUri() {
+ val mimeType = "application/pdf"
+ val previewUri = uri.buildUpon().appendQueryParameter("preview", null).build()
+ whenever(contentResolver.getType(uri)).thenReturn(mimeType)
+ val columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)
+ whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull()))
+ .thenReturn(MatrixCursor(columns).apply { addRow(arrayOf(previewUri.toString())) })
+ val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier)
+
+ testSubject.getMetadata(uri).let { fileInfo ->
+ assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri)
+ assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType)
+ assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(previewUri)
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt
new file mode 100644
index 00000000..f0813623
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.cursor
+
+import android.content.ContentInterface
+import android.content.Intent
+import android.database.MatrixCursor
+import android.net.Uri
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.service.chooser.AdditionalContentContract.Columns.URI
+import android.util.Size
+import com.android.intentresolver.util.cursor.get
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.capture
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+
+class PayloadToggleCursorResolverTest {
+ private val cursorUri = Uri.parse("content://org.pkg.app.extra")
+ private val chooserIntent = Intent()
+
+ @Test
+ fun missingSizeColumns() = runTest {
+ val uri = createUri(1)
+ val sourceCursor =
+ MatrixCursor(arrayOf(URI)).apply {
+ addRow(arrayOf(uri.toString()))
+ addRow(
+ arrayOf(
+ cursorUri.buildUpon().appendPath("should-be-ignored.png").build().toString()
+ )
+ )
+ addRow(arrayOf(null))
+ }
+ val fakeContentProvider =
+ mock<ContentInterface> {
+ on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor
+ }
+ val testSubject =
+ PayloadToggleCursorResolver(
+ fakeContentProvider,
+ cursorUri,
+ chooserIntent,
+ )
+
+ val cursor = testSubject.getCursor()
+ assertThat(cursor).isNotNull()
+ assertThat(cursor!!.count).isEqualTo(3)
+ cursor[0].let { row ->
+ assertThat(row).isNotNull()
+ assertThat(row!!.uri).isEqualTo(uri)
+ assertThat(row.previewSize).isNull()
+ }
+ assertThat(cursor[1]).isNull()
+ assertThat(cursor[2]).isNull()
+ }
+
+ @Test
+ fun testCorrectSizeValues() = runTest {
+ val uri = createUri(1)
+ val sourceCursor =
+ MatrixCursor(arrayOf(URI, WIDTH, HEIGHT)).apply {
+ addRow(arrayOf(uri.toString(), "100", "50"))
+ }
+ val fakeContentProvider =
+ mock<ContentInterface> {
+ on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor
+ }
+ val testSubject =
+ PayloadToggleCursorResolver(
+ fakeContentProvider,
+ cursorUri,
+ chooserIntent,
+ )
+
+ val cursor = testSubject.getCursor()
+ assertThat(cursor).isNotNull()
+ assertThat(cursor!!.count).isEqualTo(1)
+
+ cursor[0].let { row ->
+ assertThat(row).isNotNull()
+ assertThat(row!!.uri).isEqualTo(uri)
+ assertThat(row.previewSize).isEqualTo(Size(100, 50))
+ }
+ val columnsCaptor = argumentCaptor<Array<String>>()
+ verify(fakeContentProvider).query(eq(cursorUri), columnsCaptor.capture(), any(), any())
+ assertThat(columnsCaptor.firstValue.toList()).containsExactly(URI, WIDTH, HEIGHT)
+ }
+
+ @Test
+ fun testRowPositionValues() = runTest {
+ val rowCount = 10
+ val sourceCursor =
+ MatrixCursor(arrayOf(URI)).apply {
+ for (i in 1..rowCount) {
+ addRow(arrayOf(createUri(i).toString()))
+ }
+ }
+ val fakeContentProvider =
+ mock<ContentInterface> {
+ on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor
+ }
+ val testSubject =
+ PayloadToggleCursorResolver(
+ fakeContentProvider,
+ cursorUri,
+ chooserIntent,
+ )
+
+ val cursor = testSubject.getCursor()
+ assertThat(cursor).isNotNull()
+ assertThat(cursor!!.count).isEqualTo(rowCount)
+ for (i in 0 until rowCount) {
+ cursor[i].let { row ->
+ assertWithMessage("Row $i").that(row).isNotNull()
+ assertWithMessage("Row $i").that(row!!.position).isEqualTo(i)
+ }
+ }
+ }
+}
+
+private fun createUri(id: Int) = Uri.parse("content://org.pkg/app/img-$id.png")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt
new file mode 100644
index 00000000..7c36ef55
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.intent
+
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_STREAM
+import android.net.Uri
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class TargetIntentModifierImplTest {
+ @Test
+ fun testIntentActionChange() {
+ val testSubject =
+ TargetIntentModifierImpl<Uri>(Intent(ACTION_SEND), { this }, { "image/png" })
+
+ val u1 = createUri(1)
+ val u2 = createUri(2)
+ testSubject.intentFromSelection(listOf(u1, u2)).let { intent ->
+ assertThat(intent.action).isEqualTo(ACTION_SEND_MULTIPLE)
+ assertThat(intent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java))
+ .containsExactly(u1, u2)
+ .inOrder()
+ }
+
+ testSubject.intentFromSelection(listOf(u1)).let { intent ->
+ assertThat(intent.action).isEqualTo(ACTION_SEND)
+ assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1)
+ }
+ }
+
+ @Test
+ fun testMimeTypeChange() {
+ val testSubject =
+ TargetIntentModifierImpl<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second })
+
+ val u1 = createUri(1)
+ val u2 = createUri(2)
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/png")).let { intent
+ ->
+ assertThat(intent.type).isEqualTo("image/png")
+ }
+
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent
+ ->
+ assertThat(intent.type).isEqualTo("image/*")
+ }
+
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent
+ ->
+ assertThat(intent.type).isEqualTo("*/*")
+ }
+
+ testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to null)).let { intent ->
+ assertThat(intent.type).isEqualTo("*/*")
+ }
+ }
+
+ // TODO: test that the original intent's extras and flags remains the same
+}
+
+private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id")
+
+private data class Item(val uri: Uri, val mimeType: String?)
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt
new file mode 100644
index 00000000..5d29b4f3
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt
@@ -0,0 +1,395 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
+import android.service.chooser.AdditionalContentContract.Columns.URI
+import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION
+import android.util.Size
+import androidx.core.os.bundleOf
+import com.android.intentresolver.contentpreview.FileInfo
+import com.android.intentresolver.contentpreview.UriMetadataReader
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.readSize
+import com.android.intentresolver.contentpreview.uriMetadataReader
+import com.android.intentresolver.util.KosmosTestScope
+import com.android.intentresolver.util.cursor.CursorView
+import com.android.intentresolver.util.cursor.viewBy
+import com.android.intentresolver.util.runTest
+import com.android.systemui.kosmos.Kosmos
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import org.junit.Test
+
+class CursorPreviewsInteractorTest {
+
+ private fun runTestWithDeps(
+ initialSelection: Iterable<Int>,
+ focusedItemIndex: Int,
+ cursor: Iterable<Int>,
+ cursorStartPosition: Int,
+ pageSize: Int = 16,
+ maxLoadedPages: Int = 3,
+ cursorSizes: Map<Int, Size> = emptyMap(),
+ metadatSizes: Map<Int, Size> = emptyMap(),
+ block: KosmosTestScope.(TestDeps) -> Unit,
+ ) {
+ val metadataUriToSize = metadatSizes.mapKeys { uri(it.key) }
+ with(Kosmos()) {
+ this.focusedItemIndex = focusedItemIndex
+ this.pageSize = pageSize
+ this.maxLoadedPages = maxLoadedPages
+ this.targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ uriMetadataReader =
+ object : UriMetadataReader {
+ override fun getMetadata(uri: Uri): FileInfo =
+ FileInfo.Builder(uri)
+ .withPreviewUri(uri)
+ .withMimeType("image/bitmap")
+ .build()
+
+ override fun readPreviewSize(uri: Uri): Size? = metadataUriToSize[uri]
+ }
+ runTest {
+ block(
+ TestDeps(
+ initialSelection,
+ focusedItemIndex,
+ cursor,
+ cursorStartPosition,
+ cursorSizes,
+ )
+ )
+ }
+ }
+ }
+
+ private class TestDeps(
+ initialSelectionRange: Iterable<Int>,
+ focusedItemIndex: Int,
+ private val cursorRange: Iterable<Int>,
+ private val cursorStartPosition: Int,
+ private val cursorSizes: Map<Int, Size>,
+ ) {
+ val cursor: CursorView<CursorRow?> =
+ MatrixCursor(arrayOf(URI, WIDTH, HEIGHT))
+ .apply {
+ extras = bundleOf(POSITION to cursorStartPosition)
+ for (i in cursorRange) {
+ val size = cursorSizes[i]
+ addRow(
+ arrayOf(
+ uri(i).toString(),
+ size?.width?.toString(),
+ size?.height?.toString(),
+ )
+ )
+ }
+ }
+ .viewBy {
+ getString(0)?.let { uriStr ->
+ CursorRow(Uri.parse(uriStr), readSize(), position)
+ }
+ }
+ val initialPreviews: List<PreviewModel> =
+ initialSelectionRange.mapIndexed { index, i ->
+ PreviewModel(
+ key =
+ if (index == focusedItemIndex) {
+ PreviewKey.final(0)
+ } else {
+ PreviewKey.temp(index)
+ },
+ uri = uri(i),
+ mimeType = "image/bitmap",
+ order = i,
+ )
+ }
+ }
+
+ @Test
+ fun initialCursorLoad() =
+ runTestWithDeps(
+ initialSelection = (1..2),
+ focusedItemIndex = 1,
+ cursor = (0 until 10),
+ cursorStartPosition = 2,
+ cursorSizes = mapOf(0 to (200 x 100)),
+ metadatSizes = mapOf(0 to (300 x 100), 3 to (400 x 100)),
+ pageSize = 2,
+ maxLoadedPages = 3,
+ ) { deps ->
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ with(cursorPreviewsRepository.previewsModel.value!!) {
+ assertThat(previewModels)
+ .containsExactlyElementsIn(
+ List(6) {
+ PreviewModel(
+ key = PreviewKey.final((it - 2)),
+ uri = Uri.fromParts("scheme$it", "ssp$it", "fragment$it"),
+ mimeType = "image/bitmap",
+ aspectRatio =
+ when (it) {
+ 0 -> 2f
+ 3 -> 4f
+ else -> 1f
+ },
+ order = it,
+ )
+ }
+ )
+ .inOrder()
+ assertThat(startIdx).isEqualTo(2)
+ assertThat(loadMoreLeft).isNull()
+ assertThat(loadMoreRight).isNotNull()
+ assertThat(leftTriggerIndex).isEqualTo(2)
+ assertThat(rightTriggerIndex).isEqualTo(4)
+ }
+ }
+
+ @Test
+ fun loadMoreLeft_evictRight() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ focusedItemIndex = 0,
+ cursor = (0 until 48),
+ cursorStartPosition = 24,
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNotNull()
+
+ cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun loadMoreRight_evictLeft() =
+ runTestWithDeps(
+ initialSelection = listOf(24),
+ focusedItemIndex = 0,
+ cursor = (0 until 48),
+ cursorStartPosition = 24,
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) { deps ->
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun noMoreRight_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(24, 50),
+ focusedItemIndex = 0,
+ cursor = listOf(24),
+ cursorStartPosition = 0,
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull()
+ }
+
+ @Test
+ fun noMoreLeft_appendUnclaimedFromInitialSelection() =
+ runTestWithDeps(
+ initialSelection = listOf(0, 24),
+ focusedItemIndex = 1,
+ cursor = listOf(24),
+ cursorStartPosition = 0,
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) { deps ->
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+
+ @Test
+ fun unclaimedRecordsGotUpdatedInSelectionInteractor() =
+ runTestWithDeps(
+ initialSelection = listOf(1),
+ focusedItemIndex = 0,
+ cursor = listOf(0, 1),
+ cursorStartPosition = 1,
+ ) { deps ->
+ previewSelectionsRepository.selections.value =
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = uri(1),
+ mimeType = "image/png",
+ order = 0,
+ )
+ .let { mapOf(it.uri to it) }
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(previewSelectionsRepository.selections.value.values)
+ .containsExactly(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = uri(1),
+ mimeType = "image/bitmap",
+ order = 1,
+ )
+ )
+ }
+
+ @Test
+ fun testReadFailedPages() =
+ runTestWithDeps(
+ initialSelection = listOf(4),
+ focusedItemIndex = 0,
+ cursor = emptyList(),
+ cursorStartPosition = 0,
+ pageSize = 2,
+ maxLoadedPages = 5,
+ ) { deps ->
+ val cursor =
+ MatrixCursor(arrayOf(URI)).apply {
+ extras = bundleOf(POSITION to 4)
+ for (i in 0 until 10) {
+ addRow(arrayOf(uri(i)))
+ }
+ }
+ val failingPositions = setOf(1, 5, 8)
+ val failingCursor =
+ object : Cursor by cursor {
+ override fun move(offset: Int): Boolean = moveToPosition(position + offset)
+
+ override fun moveToPosition(position: Int): Boolean {
+ if (failingPositions.contains(position)) {
+ throw RuntimeException(
+ "A test exception when moving the cursor to position $position"
+ )
+ }
+ return cursor.moveToPosition(position)
+ }
+
+ override fun moveToFirst(): Boolean = moveToPosition(0)
+
+ override fun moveToLast(): Boolean = moveToPosition(count - 1)
+
+ override fun moveToNext(): Boolean = move(1)
+
+ override fun moveToPrevious(): Boolean = move(-1)
+ }
+ .viewBy {
+ getString(0)?.let { uriStr ->
+ CursorRow(Uri.parse(uriStr), readSize(), position)
+ }
+ }
+ backgroundScope.launch {
+ cursorPreviewsInteractor.launch(failingCursor, deps.initialPreviews)
+ }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels)
+ .comparingElementsUsing<PreviewModel, Uri>(
+ Correspondence.transforming({ it.uri }, "has a Uri of")
+ )
+ .containsExactlyElementsIn(
+ (0..7).filterNot { failingPositions.contains(it) }.map { uri(it) }
+ )
+ .inOrder()
+ }
+}
+
+private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index")
+
+private infix fun Int.x(height: Int) = Size(this, height)
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt
new file mode 100644
index 00000000..2bbda0cc
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import com.android.intentresolver.data.repository.chooserRequestRepository
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.util.comparingElementsUsingTransform
+import com.android.intentresolver.util.runKosmosTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import org.junit.Test
+
+class CustomActionsInteractorTest {
+
+ @Test
+ fun customActions_initialRepoValue() = runKosmosTest {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"),
+ initialActions =
+ listOf(
+ CustomActionModel(label = "label1", icon = icon, performAction = {}),
+ ),
+ )
+ val underTest = customActionsInteractor
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has a label of") { model: ActionModel -> model.label }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> model.icon }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+ }
+
+ @Test
+ fun customActions_tracksRepoUpdates() = runKosmosTest {
+ val underTest = customActionsInteractor
+
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ val chooserActions = listOf(CustomActionModel("label1", icon) {})
+ chooserRequestRepository.customActions.value = chooserActions
+ runCurrent()
+
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has a label of") { model: ActionModel -> model.label }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(customActions.value)
+ .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> model.icon }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+ }
+
+ @Test
+ fun customActions_performAction_sendsPendingIntent() = runKosmosTest {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ var actionSent = false
+ chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"),
+ initialActions =
+ listOf(
+ CustomActionModel(
+ label = "label1",
+ icon = icon,
+ performAction = { actionSent = true },
+ )
+ ),
+ )
+ val underTest = customActionsInteractor
+
+ val customActions: StateFlow<List<ActionModel>> =
+ underTest.customActions.stateIn(backgroundScope)
+
+ assertThat(customActions.value).hasSize(1)
+
+ customActions.value[0].performAction(123)
+
+ assertThat(actionSent).isTrue()
+ assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt
new file mode 100644
index 00000000..0a56a2d0
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt
@@ -0,0 +1,322 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.database.MatrixCursor
+import android.net.Uri
+import android.util.Size
+import androidx.core.os.bundleOf
+import com.android.intentresolver.contentpreview.FileInfo
+import com.android.intentresolver.contentpreview.UriMetadataReader
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.contentpreview.uriMetadataReader
+import com.android.intentresolver.inject.contentUris
+import com.android.intentresolver.util.KosmosTestScope
+import com.android.intentresolver.util.cursor.CursorView
+import com.android.intentresolver.util.cursor.viewBy
+import com.android.intentresolver.util.runTest as runKosmosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.junit.Test
+
+class FetchPreviewsInteractorTest {
+
+ private fun runTest(
+ initialSelection: Iterable<Int>,
+ focusedItemIndex: Int,
+ cursor: Iterable<Int>,
+ cursorStartPosition: Int,
+ pageSize: Int = 16,
+ maxLoadedPages: Int = 8,
+ previewSizes: Map<Int, Size> = emptyMap(),
+ block: KosmosTestScope.() -> Unit,
+ ) {
+ val previewUriToSize = previewSizes.mapKeys { uri(it.key) }
+ with(Kosmos()) {
+ fakeCursorResolver =
+ FakeCursorResolver(cursorRange = cursor, cursorStartPosition = cursorStartPosition)
+ payloadToggleCursorResolver = fakeCursorResolver
+ contentUris = initialSelection.map { uri(it) }
+ this.focusedItemIndex = focusedItemIndex
+ uriMetadataReader =
+ object : UriMetadataReader {
+ override fun getMetadata(uri: Uri): FileInfo =
+ FileInfo.Builder(uri)
+ .withPreviewUri(uri)
+ .withMimeType("image/bitmap")
+ .build()
+
+ override fun readPreviewSize(uri: Uri): Size? = previewUriToSize[uri]
+ }
+ this.pageSize = pageSize
+ this.maxLoadedPages = maxLoadedPages
+ this.targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ runKosmosTest { block() }
+ }
+ }
+
+ private var Kosmos.fakeCursorResolver: FakeCursorResolver by Fixture()
+
+ private class FakeCursorResolver(
+ private val cursorRange: Iterable<Int>,
+ private val cursorStartPosition: Int,
+ ) : CursorResolver<CursorRow?> {
+ private val mutex = Mutex(locked = true)
+
+ fun complete() = mutex.unlock()
+
+ override suspend fun getCursor(): CursorView<CursorRow?> =
+ mutex.withLock {
+ MatrixCursor(arrayOf("uri"))
+ .apply {
+ extras = bundleOf("position" to cursorStartPosition)
+ for (i in cursorRange) {
+ newRow().add("uri", uri(i).toString())
+ }
+ }
+ .viewBy { getString(0)?.let(Uri::parse)?.let { CursorRow(it, null, position) } }
+ }
+ }
+
+ @Test
+ fun setsInitialPreviews() =
+ runTest(
+ initialSelection = (1..3),
+ focusedItemIndex = 1,
+ cursor = (0 until 4),
+ cursorStartPosition = 1,
+ previewSizes = mapOf(1 to Size(100, 50)),
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value)
+ .isEqualTo(
+ PreviewsModel(
+ previewModels =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.temp(0),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "image/bitmap",
+ aspectRatio = 2f,
+ order = Int.MIN_VALUE,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.temp(2),
+ uri = Uri.fromParts("scheme3", "ssp3", "fragment3"),
+ mimeType = "image/bitmap",
+ order = Int.MAX_VALUE,
+ ),
+ ),
+ startIdx = 1,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 2,
+ )
+ )
+ }
+
+ @Test
+ fun lookupCursorFromContentResolver() =
+ runTest(
+ initialSelection = (1..2),
+ focusedItemIndex = 1,
+ cursor = (0 until 4),
+ cursorStartPosition = 2,
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ fakeCursorResolver.complete()
+ runCurrent()
+
+ with(cursorPreviewsRepository) {
+ assertThat(previewsModel.value).isNotNull()
+ assertThat(previewsModel.value!!.startIdx).isEqualTo(2)
+ assertThat(previewsModel.value!!.loadMoreLeft).isNull()
+ assertThat(previewsModel.value!!.loadMoreRight).isNull()
+ assertThat(previewsModel.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(
+ key = PreviewKey.final(-2),
+ uri = Uri.fromParts("scheme0", "ssp0", "fragment0"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(-1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "image/bitmap",
+ order = 1,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 2,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme3", "ssp3", "fragment3"),
+ mimeType = "image/bitmap",
+ order = 3,
+ ),
+ )
+ .inOrder()
+ }
+ }
+
+ @Test
+ fun loadMoreLeft_evictRight() =
+ runTest(
+ initialSelection = listOf(24),
+ focusedItemIndex = 0,
+ cursor = (0 until 48),
+ cursorStartPosition = 24,
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ fakeCursorResolver.complete()
+ runCurrent()
+
+ with(cursorPreviewsRepository) {
+ assertThat(previewsModel.value).isNotNull()
+ assertThat(previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(previewsModel.value!!.loadMoreLeft).isNotNull()
+ }
+
+ cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke()
+ runCurrent()
+
+ with(cursorPreviewsRepository) {
+ assertThat(previewsModel.value).isNotNull()
+ assertThat(previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15"))
+ assertThat(previewsModel.value!!.loadMoreLeft).isNull()
+ }
+ }
+
+ @Test
+ fun loadMoreRight_evictLeft() =
+ runTest(
+ initialSelection = listOf(24),
+ focusedItemIndex = 0,
+ cursor = (0 until 48),
+ cursorStartPosition = 24,
+ pageSize = 16,
+ maxLoadedPages = 1,
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ fakeCursorResolver.complete()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull()
+
+ cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47"))
+ }
+
+ @Test
+ fun noMoreRight_appendUnclaimedFromInitialSelection() =
+ runTest(
+ initialSelection = listOf(24, 50),
+ focusedItemIndex = 0,
+ cursor = listOf(24),
+ cursorStartPosition = 0,
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ fakeCursorResolver.complete()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull()
+ }
+
+ @Test
+ fun noMoreLeft_appendUnclaimedFromInitialSelection() =
+ runTest(
+ initialSelection = listOf(0, 24),
+ focusedItemIndex = 1,
+ cursor = listOf(24),
+ cursorStartPosition = 0,
+ pageSize = 16,
+ maxLoadedPages = 2,
+ ) {
+ backgroundScope.launch { fetchPreviewsInteractor.activate() }
+ fakeCursorResolver.complete()
+ runCurrent()
+
+ assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull()
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2)
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri)
+ .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri)
+ .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24"))
+ assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull()
+ }
+}
+
+private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt
new file mode 100644
index 00000000..0268a4d5
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.data.repository.chooserRequestRepository
+import com.android.intentresolver.logging.FakeEventLog
+import com.android.intentresolver.util.runKosmosTest
+import com.android.internal.logging.InstanceId
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import org.junit.Test
+
+class SelectablePreviewInteractorTest {
+ private val eventLog = FakeEventLog(InstanceId.fakeInstanceId(0))
+
+ @Test
+ fun reflectPreviewRepo_initState() = runKosmosTest {
+ targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ val underTest =
+ SelectablePreviewInteractor(
+ key =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ ),
+ selectionInteractor = selectionInteractor,
+ eventLog = eventLog,
+ )
+ runCurrent()
+
+ assertThat(underTest.isSelected.first()).isFalse()
+ }
+
+ @Test
+ fun reflectPreviewRepo_updatedState() = runKosmosTest {
+ targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ val underTest =
+ SelectablePreviewInteractor(
+ key =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ selectionInteractor = selectionInteractor,
+ eventLog = eventLog,
+ )
+
+ assertThat(underTest.isSelected.first()).isFalse()
+
+ previewSelectionsRepository.selections.value =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ )
+ .let { mapOf(it.uri to it) }
+ runCurrent()
+
+ assertThat(underTest.isSelected.first()).isTrue()
+ }
+
+ @Test
+ fun setSelected_updatesChooserRequestRepo() = runKosmosTest {
+ val modifiedIntent = Intent()
+ targetIntentModifier = TargetIntentModifier { modifiedIntent }
+ val underTest =
+ SelectablePreviewInteractor(
+ key =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ selectionInteractor = selectionInteractor,
+ eventLog = eventLog,
+ )
+
+ underTest.setSelected(true)
+ runCurrent()
+
+ assertThat(previewSelectionsRepository.selections.value.keys)
+ .containsExactly(Uri.fromParts("scheme", "ssp", "fragment"))
+
+ assertThat(chooserRequestRepository.chooserRequest.value.targetIntent)
+ .isSameInstanceAs(modifiedIntent)
+ assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value)
+ .isSameInstanceAs(modifiedIntent)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt
new file mode 100644
index 00000000..c90a3091
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.util.runKosmosTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import org.junit.Test
+
+class SelectablePreviewsInteractorTest {
+
+ @Test
+ fun keySet_reflectsRepositoryInit() = runKosmosTest {
+ cursorPreviewsRepository.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 1,
+ ),
+ ),
+ startIdx = 0,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 1,
+ )
+ previewSelectionsRepository.selections.value =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ .let { mapOf(it.uri to it) }
+ targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ val underTest = selectablePreviewsInteractor
+ val keySet = underTest.previews.stateIn(backgroundScope)
+
+ assertThat(keySet.value).isNotNull()
+ assertThat(keySet.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 1,
+ ),
+ )
+ .inOrder()
+ assertThat(keySet.value!!.startIdx).isEqualTo(0)
+ assertThat(keySet.value!!.loadMoreLeft).isNull()
+ assertThat(keySet.value!!.loadMoreRight).isNull()
+
+ val firstModel =
+ underTest.preview(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ )
+ assertThat(firstModel.isSelected.first()).isTrue()
+
+ val secondModel =
+ underTest.preview(
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = null,
+ order = 1,
+ )
+ )
+ assertThat(secondModel.isSelected.first()).isFalse()
+ }
+
+ @Test
+ fun keySet_reflectsRepositoryUpdate() = runKosmosTest {
+ previewSelectionsRepository.selections.value =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ .let { mapOf(it.uri to it) }
+ targetIntentModifier = TargetIntentModifier { error("unexpected invocation") }
+ val underTest = selectablePreviewsInteractor
+
+ val previews = underTest.previews.stateIn(backgroundScope)
+ val firstModel =
+ underTest.preview(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ )
+
+ assertThat(previews.value).isNull()
+ assertThat(firstModel.isSelected.first()).isTrue()
+
+ var loadRequested = false
+
+ cursorPreviewsRepository.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 1,
+ ),
+ ),
+ startIdx = 5,
+ loadMoreLeft = null,
+ loadMoreRight = { loadRequested = true },
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 1,
+ )
+ previewSelectionsRepository.selections.value = emptyMap()
+ runCurrent()
+
+ assertThat(previews.value).isNotNull()
+ assertThat(previews.value!!.previewModels)
+ .containsExactly(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/bitmap",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = "image/bitmap",
+ order = 1,
+ ),
+ )
+ .inOrder()
+ assertThat(previews.value!!.startIdx).isEqualTo(5)
+ assertThat(previews.value!!.loadMoreLeft).isNull()
+ assertThat(previews.value!!.loadMoreRight).isNotNull()
+
+ assertThat(firstModel.isSelected.first()).isFalse()
+
+ previews.value!!.loadMoreRight!!.invoke()
+
+ assertThat(loadRequested).isTrue()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt
new file mode 100644
index 00000000..c24138b8
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.Intent
+import android.net.Uri
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import com.android.intentresolver.Flags
+import com.android.intentresolver.contentpreview.mimetypeClassifier
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.util.runKosmosTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.first
+import org.junit.Rule
+import org.junit.Test
+
+class SelectionInteractorTest {
+ @get:Rule val flagsRule = SetFlagsRule()
+
+ @Test
+ @DisableFlags(Flags.FLAG_UNSELECT_FINAL_ITEM)
+ fun singleSelection_removalPrevented() = runKosmosTest {
+ val initialPreview =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview)
+
+ val underTest =
+ SelectionInteractor(
+ previewSelectionsRepository,
+ { Intent() },
+ updateTargetIntentInteractor,
+ mimetypeClassifier,
+ )
+
+ assertThat(underTest.selections.first()).containsExactly(initialPreview.uri)
+
+ // Shouldn't do anything!
+ underTest.unselect(initialPreview)
+
+ assertThat(underTest.selections.first()).containsExactly(initialPreview.uri)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_UNSELECT_FINAL_ITEM)
+ fun singleSelection_itemRemovedNoPendingIntentUpdates() = runKosmosTest {
+ val initialPreview =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview)
+
+ val underTest =
+ SelectionInteractor(
+ previewSelectionsRepository,
+ { Intent() },
+ updateTargetIntentInteractor,
+ mimetypeClassifier,
+ )
+
+ assertThat(underTest.selections.first()).containsExactly(initialPreview.uri)
+
+ underTest.unselect(initialPreview)
+
+ assertThat(underTest.selections.first()).isEmpty()
+ assertThat(previewSelectionsRepository.selections.value).isEmpty()
+ }
+
+ @Test
+ fun multipleSelections_removalAllowed() = runKosmosTest {
+ val first =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ val second =
+ PreviewModel(
+ key = PreviewKey.final(2),
+ uri = Uri.fromParts("scheme2", "ssp2", "fragment2"),
+ mimeType = null,
+ order = 1,
+ )
+ previewSelectionsRepository.selections.value = listOf(first, second).associateBy { it.uri }
+
+ val underTest =
+ SelectionInteractor(
+ previewSelectionsRepository,
+ { Intent() },
+ updateTargetIntentInteractor,
+ mimetypeClassifier,
+ )
+
+ underTest.unselect(first)
+
+ assertThat(underTest.selections.first()).containsExactly(second.uri)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt
new file mode 100644
index 00000000..42f1a1b2
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.net.Uri
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.util.runKosmosTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import org.junit.Test
+
+class SetCursorPreviewsInteractorTest {
+ @Test
+ fun setPreviews_noAdditionalData() = runKosmosTest {
+ val loadState =
+ setCursorPreviewsInteractor.setPreviews(
+ previews =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ ),
+ startIndex = 100,
+ hasMoreLeft = false,
+ hasMoreRight = false,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 0,
+ )
+
+ assertThat(loadState.first()).isNull()
+ cursorPreviewsRepository.previewsModel.value.let {
+ assertThat(it).isNotNull()
+ it!!
+ assertThat(it.loadMoreRight).isNull()
+ assertThat(it.loadMoreLeft).isNull()
+ assertThat(it.startIdx).isEqualTo(100)
+ assertThat(it.previewModels)
+ .containsExactly(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ )
+ .inOrder()
+ }
+ }
+
+ @Test
+ fun setPreviews_additionalData() = runKosmosTest {
+ val loadState =
+ setCursorPreviewsInteractor
+ .setPreviews(
+ previews =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ ),
+ startIndex = 100,
+ hasMoreLeft = true,
+ hasMoreRight = true,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 0,
+ )
+ .stateIn(backgroundScope)
+
+ assertThat(loadState.value).isNull()
+ cursorPreviewsRepository.previewsModel.value.let {
+ assertThat(it).isNotNull()
+ it!!
+ assertThat(it.loadMoreRight).isNotNull()
+ assertThat(it.loadMoreLeft).isNotNull()
+
+ it.loadMoreRight!!.invoke()
+ runCurrent()
+ assertThat(loadState.value).isEqualTo(LoadDirection.Right)
+
+ it.loadMoreLeft!!.invoke()
+ runCurrent()
+ assertThat(loadState.value).isEqualTo(LoadDirection.Left)
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt
new file mode 100644
index 00000000..32d040fe
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
+
+import android.content.ComponentName
+import android.content.Intent
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import com.android.intentresolver.Flags.FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback
+import com.android.intentresolver.data.repository.chooserRequestRepository
+import com.android.intentresolver.util.runKosmosTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import org.junit.Rule
+import org.junit.Test
+
+class UpdateChooserRequestInteractorTest {
+ @get:Rule val setFlagsRule = SetFlagsRule()
+
+ @Test
+ fun updateTargetIntentWithSelection() = runKosmosTest {
+ val selectionCallbackResult = ShareouselUpdate(metadataText = ValueUpdate.Value("update"))
+ selectionChangeCallback = SelectionChangeCallback { selectionCallbackResult }
+
+ backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() }
+
+ updateTargetIntentInteractor.updateTargetIntent(Intent())
+ runCurrent()
+
+ assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value).isNull()
+ assertThat(chooserRequestRepository.chooserRequest.value.metadataText).isEqualTo("update")
+ }
+
+ @Test
+ @EnableFlags(FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA)
+ fun testSelectionResultWithExcludedComponents_chooserRequestIsUpdated() = runKosmosTest {
+ val excludedComponent = ComponentName("org.pkg.app", "Class")
+ val selectionCallbackResult =
+ ShareouselUpdate(excludeComponents = ValueUpdate.Value(listOf(excludedComponent)))
+ selectionChangeCallback = SelectionChangeCallback { selectionCallbackResult }
+
+ backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() }
+
+ updateTargetIntentInteractor.updateTargetIntent(Intent())
+ runCurrent()
+
+ assertThat(chooserRequestRepository.chooserRequest.value.filteredComponentNames)
+ .containsExactly(excludedComponent)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt
new file mode 100644
index 00000000..c1a1833a
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt
@@ -0,0 +1,471 @@
+/*
+ * Copyright 2024 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.intentresolver.contentpreview.payloadtoggle.domain.update
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.ContentInterface
+import android.content.Intent
+import android.content.Intent.ACTION_CHOOSER
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_METADATA_TEXT
+import android.content.Intent.EXTRA_STREAM
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.os.Bundle
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.Flags.FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate.Absent
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Correspondence.BinaryPredicate
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.lang.IllegalArgumentException
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class SelectionChangeCallbackImplTest {
+ @get:Rule val setFlagsRule = SetFlagsRule()
+
+ private val uri = Uri.parse("content://org.pkg/content-provider")
+ private val chooserIntent = Intent(ACTION_CHOOSER)
+ private val contentResolver = mock<ContentInterface>()
+ private val context = InstrumentationRegistry.getInstrumentation().context
+
+ @Test
+ fun testPayloadChangeCallbackContact() = runTest {
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val u1 = createUri(1)
+ val u2 = createUri(2)
+ val targetIntent =
+ Intent(ACTION_SEND_MULTIPLE).apply {
+ val uris =
+ ArrayList<Uri>().apply {
+ add(u1)
+ add(u2)
+ }
+ putExtra(EXTRA_STREAM, uris)
+ type = "image/jpg"
+ }
+ testSubject.onSelectionChanged(targetIntent)
+
+ val authorityCaptor = argumentCaptor<String>()
+ val methodCaptor = argumentCaptor<String>()
+ val argCaptor = argumentCaptor<String>()
+ val extraCaptor = argumentCaptor<Bundle>()
+ verify(contentResolver, times(1))
+ .call(
+ authorityCaptor.capture(),
+ methodCaptor.capture(),
+ argCaptor.capture(),
+ extraCaptor.capture()
+ )
+ assertWithMessage("Wrong additional content provider authority")
+ .that(authorityCaptor.firstValue)
+ .isEqualTo(uri.authority)
+ assertWithMessage("Wrong additional content provider #call() method name")
+ .that(methodCaptor.firstValue)
+ .isEqualTo(ON_SELECTION_CHANGED)
+ assertWithMessage("Wrong additional content provider argument value")
+ .that(argCaptor.firstValue)
+ .isEqualTo(uri.toString())
+ val extraBundle = extraCaptor.firstValue
+ assertWithMessage("Additional content provider #call() should have a non-null extras arg.")
+ .that(extraBundle)
+ .isNotNull()
+ val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java)
+ assertWithMessage("#call() extras arg. should contain Intent#EXTRA_INTENT")
+ .that(argChooserIntent)
+ .isNotNull()
+ requireNotNull(argChooserIntent)
+ assertWithMessage("#call() extras arg's Intent#EXTRA_INTENT should be a Chooser intent")
+ .that(argChooserIntent.action)
+ .isEqualTo(chooserIntent.action)
+ val argTargetIntent = argChooserIntent.getParcelableExtra(EXTRA_INTENT, Intent::class.java)
+ assertWithMessage(
+ "A chooser intent passed into #call() method should contain updated target intent"
+ )
+ .that(argTargetIntent)
+ .isNotNull()
+ requireNotNull(argTargetIntent)
+ assertWithMessage("Incorrect target intent")
+ .that(argTargetIntent.action)
+ .isEqualTo(targetIntent.action)
+ assertWithMessage("Incorrect target intent")
+ .that(argTargetIntent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java))
+ .containsExactly(u1, u2)
+ .inOrder()
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesCustomActions() = runTest {
+ val a1 =
+ ChooserAction.Builder(
+ Icon.createWithContentUri(createUri(10)),
+ "Action 1",
+ PendingIntent.getBroadcast(
+ context,
+ 1,
+ Intent("test"),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ .build()
+ val a2 =
+ ChooserAction.Builder(
+ Icon.createWithContentUri(createUri(11)),
+ "Action 2",
+ PendingIntent.getBroadcast(
+ context,
+ 1,
+ Intent("test"),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ .build()
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND_MULTIPLE)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertWithMessage("Unexpected custom actions")
+ .that(result.customActions.getOrThrow().map { it.icon to it.label })
+ .containsExactly(a1.icon to a1.label, a2.icon to a2.label)
+ .inOrder()
+
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesReselectionAction() = runTest {
+ val modifyShare =
+ ChooserAction.Builder(
+ Icon.createWithContentUri(createUri(10)),
+ "Modify Share",
+ PendingIntent.getBroadcast(
+ context,
+ 1,
+ Intent("test"),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ .build()
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertWithMessage("Unexpected modify share action: wrong icon")
+ .that(result.modifyShareAction.getOrThrow()?.icon)
+ .isEqualTo(modifyShare.icon)
+ assertWithMessage("Unexpected modify share action: wrong label")
+ .that(result.modifyShareAction.getOrThrow()?.label)
+ .isEqualTo(modifyShare.label)
+
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesAlternateIntents() = runTest {
+ val alternateIntents =
+ arrayOf(
+ Intent(ACTION_SEND_MULTIPLE).apply {
+ addCategory("test")
+ type = ""
+ }
+ )
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertWithMessage("Wrong number of alternate intents")
+ .that(result.alternateIntents.getOrThrow())
+ .hasSize(1)
+ assertWithMessage("Wrong alternate intent: action")
+ .that(result.alternateIntents.getOrThrow()[0].action)
+ .isEqualTo(alternateIntents[0].action)
+ assertWithMessage("Wrong alternate intent: categories")
+ .that(result.alternateIntents.getOrThrow()[0].categories)
+ .containsExactlyElementsIn(alternateIntents[0].categories)
+ assertWithMessage("Wrong alternate intent: mime type")
+ .that(result.alternateIntents.getOrThrow()[0].type)
+ .isEqualTo(alternateIntents[0].type)
+
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesCallerTargets() = runTest {
+ val t1 =
+ ChooserTarget(
+ "Target 1",
+ Icon.createWithContentUri(createUri(1)),
+ 0.99f,
+ ComponentName("org.pkg.app", ".ClassA"),
+ null
+ )
+ val t2 =
+ ChooserTarget(
+ "Target 2",
+ Icon.createWithContentUri(createUri(1)),
+ 1f,
+ ComponentName("org.pkg.app", ".ClassB"),
+ null
+ )
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertWithMessage("Wrong caller targets")
+ .that(result.callerTargets.getOrThrow())
+ .comparingElementsUsing(
+ Correspondence.from(
+ BinaryPredicate<ChooserTarget?, ChooserTarget> { actual, expected ->
+ expected.componentName == actual?.componentName &&
+ expected.title == actual?.title &&
+ expected.icon == actual?.icon &&
+ expected.score == actual?.score
+ },
+ ""
+ )
+ )
+ .containsExactly(t1, t2)
+ .inOrder()
+
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesRefinementIntentSender() = runTest {
+ val broadcast =
+ PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE)
+
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply {
+ putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, broadcast.intentSender)
+ }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender.getOrThrow()).isNotNull()
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesResultIntentSender() = runTest {
+ val broadcast =
+ PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE)
+
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply {
+ putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, broadcast.intentSender)
+ }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender.getOrThrow()).isNotNull()
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest {
+ val metadataText = "[Metadata]"
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) })
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText.getOrThrow()).isEqualTo(metadataText)
+ assertThat(result.excludeComponents).isEqualTo(Absent)
+ }
+
+ @Test
+ @EnableFlags(FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA)
+ fun testPayloadChangeCallbackUpdatesExcludedComponents_valueUpdated() = runTest {
+ val excludedComponent = ComponentName("org.pkg.app", "org.pkg.app.TheClass")
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply {
+ putParcelableArray(EXTRA_EXCLUDE_COMPONENTS, arrayOf(excludedComponent))
+ }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions).isEqualTo(Absent)
+ assertThat(result.modifyShareAction).isEqualTo(Absent)
+ assertThat(result.alternateIntents).isEqualTo(Absent)
+ assertThat(result.callerTargets).isEqualTo(Absent)
+ assertThat(result.refinementIntentSender).isEqualTo(Absent)
+ assertThat(result.resultIntentSender).isEqualTo(Absent)
+ assertThat(result.metadataText).isEqualTo(Absent)
+ assertThat(result.excludeComponents.getOrThrow()).containsExactly(excludedComponent)
+ }
+
+ @Test
+ fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest {
+ whenever(contentResolver.call(any<String>(), any(), any(), any()))
+ .thenReturn(
+ Bundle().apply {
+ putParcelableArrayList(EXTRA_CHOOSER_CUSTOM_ACTIONS, ArrayList<ChooserAction>())
+ putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, createUri(1))
+ putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList<Intent>())
+ putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList<ChooserTarget>())
+ putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2))
+ putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, createUri(1))
+ putInt(EXTRA_METADATA_TEXT, 123)
+ }
+ )
+
+ val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver)
+
+ val targetIntent = Intent(ACTION_SEND)
+ val result = testSubject.onSelectionChanged(targetIntent)
+ assertWithMessage("Callback result should not be null").that(result).isNotNull()
+ requireNotNull(result)
+ assertThat(result.customActions.getOrThrow()).isEmpty()
+ assertThat(result.modifyShareAction.getOrThrow()).isNull()
+ assertThat(result.alternateIntents.getOrThrow()).isEmpty()
+ assertThat(result.callerTargets.getOrThrow()).isEmpty()
+ assertThat(result.refinementIntentSender.getOrThrow()).isNull()
+ assertThat(result.resultIntentSender.getOrThrow()).isNull()
+ assertThat(result.metadataText.getOrThrow()).isNull()
+ }
+}
+
+private fun <T> ValueUpdate<T>.getOrThrow(): T =
+ when (this) {
+ is ValueUpdate.Value -> value
+ else -> throw IllegalArgumentException("Value is expected")
+ }
+
+private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png")
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt
new file mode 100644
index 00000000..6dd96040
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt
@@ -0,0 +1,373 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import android.net.Uri
+import com.android.intentresolver.FakeImageLoader
+import com.android.intentresolver.contentpreview.HeadlineGenerator
+import com.android.intentresolver.contentpreview.mimetypeClassifier
+import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pendingIntentSender
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.chooserRequestInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.customActionsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.headlineGenerator
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectablePreviewsInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor
+import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.chooserRequestRepository
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.logging.FakeEventLog
+import com.android.intentresolver.logging.eventLog
+import com.android.intentresolver.util.KosmosTestScope
+import com.android.intentresolver.util.comparingElementsUsingTransform
+import com.android.intentresolver.util.runKosmosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import org.junit.Test
+
+class ShareouselViewModelTest {
+
+ private var Kosmos.viewModelScope: CoroutineScope by Fixture()
+ private val Kosmos.shareouselViewModel: ShareouselViewModel by Fixture {
+ ShareouselViewModelModule.create(
+ interactor = selectablePreviewsInteractor,
+ imageLoader = payloadToggleImageLoader,
+ actionsInteractor = customActionsInteractor,
+ headlineGenerator = headlineGenerator,
+ chooserRequestInteractor = chooserRequestInteractor,
+ mimeTypeClassifier = mimetypeClassifier,
+ selectionInteractor = selectionInteractor,
+ scope = viewModelScope,
+ )
+ }
+ private val previewHeight = 500
+
+ @Test
+ fun headline_images() = runTest {
+ assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 1")
+ previewSelectionsRepository.selections.value =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/png",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "image/jpeg",
+ order = 1,
+ ),
+ )
+ .associateBy { it.uri }
+ runCurrent()
+ assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2")
+ }
+
+ @Test
+ fun headline_videos() = runTest {
+ previewSelectionsRepository.selections.value =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "video/mpeg",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "video/mpeg",
+ order = 1,
+ ),
+ )
+ .associateBy { it.uri }
+ runCurrent()
+ assertThat(shareouselViewModel.headline.first()).isEqualTo("VIDEOS: 2")
+ }
+
+ @Test
+ fun headline_mixed() = runTest {
+ previewSelectionsRepository.selections.value =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/jpeg",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "video/mpeg",
+ order = 1,
+ ),
+ )
+ .associateBy { it.uri }
+ runCurrent()
+ assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 2")
+ }
+
+ @Test
+ fun metadataText() = runTest {
+ val request =
+ ChooserRequest(
+ targetIntent = Intent(),
+ launchedFromPackage = "",
+ metadataText = "Hello",
+ )
+ chooserRequestRepository.chooserRequest.value = request
+
+ runCurrent()
+
+ assertThat(shareouselViewModel.metadataText.first()).isEqualTo("Hello")
+ }
+
+ @Test
+ fun previews() =
+ runTest(targetIntentModifier = { Intent() }) {
+ cursorPreviewsRepository.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/png",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "video/mpeg",
+ order = 1,
+ ),
+ ),
+ startIdx = 1,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 1,
+ )
+ runCurrent()
+
+ assertWithMessage("previewsKeys is null")
+ .that(shareouselViewModel.previews.first())
+ .isNotNull()
+ assertThat(shareouselViewModel.previews.first()!!.previewModels)
+ .comparingElementsUsingTransform("has uri of") { it: PreviewModel -> it.uri }
+ .containsExactly(
+ Uri.fromParts("scheme", "ssp", "fragment"),
+ Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ )
+ .inOrder()
+
+ val previewVm =
+ shareouselViewModel.preview.invoke(
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "video/mpeg",
+ order = 0,
+ ),
+ previewHeight,
+ /* index = */ 1,
+ viewModelScope,
+ )
+
+ runCurrent()
+
+ assertWithMessage("preview bitmap is null")
+ .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value)
+ .isNotNull()
+ assertThat(previewVm.isSelected.first()).isFalse()
+ assertThat(previewVm.contentType).isEqualTo(ContentType.Video)
+
+ previewVm.setSelected(true)
+
+ assertThat(previewSelectionsRepository.selections.value.keys)
+ .contains(Uri.fromParts("scheme1", "ssp1", "fragment1"))
+ }
+
+ @Test
+ fun previews_wontLoad() =
+ runTest(targetIntentModifier = { Intent() }) {
+ cursorPreviewsRepository.previewsModel.value =
+ PreviewsModel(
+ previewModels =
+ listOf(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "image/png",
+ order = 0,
+ ),
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme1", "ssp1", "fragment1"),
+ mimeType = "video/mpeg",
+ order = 1,
+ ),
+ ),
+ startIdx = 1,
+ loadMoreLeft = null,
+ loadMoreRight = null,
+ leftTriggerIndex = 0,
+ rightTriggerIndex = 1,
+ )
+ runCurrent()
+
+ val previewVm =
+ shareouselViewModel.preview.invoke(
+ PreviewModel(
+ key = PreviewKey.final(0),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = "video/mpeg",
+ order = 1,
+ ),
+ previewHeight,
+ /* index = */ 1,
+ viewModelScope,
+ )
+
+ runCurrent()
+
+ assertWithMessage("preview bitmap is not null")
+ .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value)
+ .isNull()
+ }
+
+ @Test
+ fun actions() {
+ runTest {
+ assertThat(shareouselViewModel.actions.first()).isEmpty()
+
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ val icon = Icon.createWithBitmap(bitmap)
+ var actionSent = false
+ chooserRequestRepository.customActions.value =
+ listOf(
+ CustomActionModel(
+ label = "label1",
+ icon = icon,
+ performAction = { actionSent = true },
+ )
+ )
+ runCurrent()
+
+ assertThat(shareouselViewModel.actions.first())
+ .comparingElementsUsingTransform("has a label of") { vm: ActionChipViewModel ->
+ vm.label
+ }
+ .containsExactly("label1")
+ .inOrder()
+ assertThat(shareouselViewModel.actions.first())
+ .comparingElementsUsingTransform("has an icon of") { vm: ActionChipViewModel ->
+ vm.icon
+ }
+ .containsExactly(BitmapIcon(icon.bitmap))
+ .inOrder()
+
+ shareouselViewModel.actions.first()[0].onClicked()
+
+ assertThat(actionSent).isTrue()
+ assertThat(eventLog.customActionSelected)
+ .isEqualTo(FakeEventLog.CustomActionSelected(0))
+ assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK)
+ }
+ }
+
+ private fun runTest(
+ pendingIntentSender: PendingIntentSender = PendingIntentSender {},
+ targetIntentModifier: TargetIntentModifier<PreviewModel> = TargetIntentModifier {
+ error("unexpected invocation")
+ },
+ block: suspend KosmosTestScope.() -> Unit,
+ ): Unit = runKosmosTest {
+ viewModelScope = backgroundScope
+ this.pendingIntentSender = pendingIntentSender
+ this.targetIntentModifier = targetIntentModifier
+ previewSelectionsRepository.selections.value =
+ PreviewModel(
+ key = PreviewKey.final(1),
+ uri = Uri.fromParts("scheme", "ssp", "fragment"),
+ mimeType = null,
+ order = 0,
+ )
+ .let { mapOf(it.uri to it) }
+ payloadToggleImageLoader =
+ FakeImageLoader(
+ initialBitmaps =
+ mapOf(
+ Uri.fromParts("scheme1", "ssp1", "fragment1") to
+ Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8)
+ )
+ )
+ headlineGenerator =
+ object : HeadlineGenerator {
+ override fun getImagesHeadline(count: Int): String = "IMAGES: $count"
+
+ override fun getTextHeadline(text: CharSequence): String = error("not supported")
+
+ override fun getAlbumHeadline(): String = error("not supported")
+
+ override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String =
+ error("not supported")
+
+ override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String =
+ error("not supported")
+
+ override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String =
+ error("not supported")
+
+ override fun getVideosHeadline(count: Int): String = "VIDEOS: $count"
+
+ override fun getFilesHeadline(count: Int): String = "FILES: $count"
+
+ override fun getNotItemsSelectedHeadline() = "Select items to share"
+ }
+ // instantiate the view model, and then runCurrent() so that it is fully hydrated before
+ // starting the test
+ shareouselViewModel
+ runCurrent()
+ block()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt
new file mode 100644
index 00000000..ca60824d
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt
@@ -0,0 +1,89 @@
+@file:Suppress("OPT_IN_USAGE")
+
+package com.android.intentresolver.coroutines
+
+/*
+ * Copyright (C) 2022 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.
+ */
+
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the last collected value.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val actual by collectLastValue(underTest.flow)
+ * assertThat(actual).isEqualTo(expected)
+ * }
+ * ```
+ */
+fun <T> TestScope.collectLastValue(
+ flow: Flow<T>,
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+): FlowValue<T?> {
+ val values by
+ collectValues(
+ flow = flow,
+ context = context,
+ start = start,
+ )
+ return FlowValueImpl { values.lastOrNull() }
+}
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+fun <T> TestScope.collectValues(
+ flow: Flow<T>,
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+): FlowValue<List<T>> {
+ val values = mutableListOf<T>()
+ backgroundScope.launch(context, start) { flow.collect(values::add) }
+ return FlowValueImpl {
+ runCurrent()
+ values.toList()
+ }
+}
+
+/** @see collectLastValue */
+interface FlowValue<T> : ReadOnlyProperty<Any?, T> {
+ operator fun invoke(): T
+}
+
+private class FlowValueImpl<T>(private val block: () -> T) : FlowValue<T> {
+ override operator fun invoke(): T = block()
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T = invoke()
+}
diff --git a/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt
new file mode 100644
index 00000000..2fad37f2
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import com.android.intentresolver.coroutines.collectLastValue
+import com.android.intentresolver.shared.model.User
+import com.google.common.truth.Truth.assertThat
+import kotlin.random.Random
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class FakeUserRepositoryTest {
+ private val baseId = Random.nextInt(1000, 2000)
+
+ private val personalUser = User(id = baseId, role = User.Role.PERSONAL)
+ private val cloneUser = User(id = baseId + 1, role = User.Role.CLONE)
+ private val workUser = User(id = baseId + 2, role = User.Role.WORK)
+ private val privateUser = User(id = baseId + 3, role = User.Role.PRIVATE)
+
+ @Test
+ fun init() = runTest {
+ val repo = FakeUserRepository(listOf(personalUser, workUser, privateUser))
+
+ val users by collectLastValue(repo.users)
+ assertThat(users).containsExactly(personalUser, workUser, privateUser)
+ }
+
+ @Test
+ fun addUser() = runTest {
+ val repo = FakeUserRepository(emptyList())
+
+ val users by collectLastValue(repo.users)
+ assertThat(users).isEmpty()
+
+ repo.addUser(personalUser, true)
+ assertThat(users).containsExactly(personalUser)
+
+ repo.addUser(workUser, false)
+ assertThat(users).containsExactly(personalUser, workUser)
+ }
+
+ @Test
+ fun removeUser() = runTest {
+ val repo = FakeUserRepository(listOf(personalUser, workUser))
+
+ val users by collectLastValue(repo.users)
+ repo.removeUser(workUser)
+ assertThat(users).containsExactly(personalUser)
+
+ repo.removeUser(personalUser)
+ assertThat(users).isEmpty()
+ }
+
+ @Test
+ fun isAvailable_defaultValue() = runTest {
+ val repo = FakeUserRepository(listOf(personalUser, workUser))
+
+ val available by collectLastValue(repo.availability)
+
+ repo.requestState(workUser, false)
+ assertThat(available!![workUser]).isFalse()
+
+ repo.requestState(workUser, true)
+ assertThat(available!![workUser]).isTrue()
+ }
+
+ @Test
+ fun isAvailable() = runTest {
+ val repo = FakeUserRepository(listOf(personalUser, workUser))
+
+ val available by collectLastValue(repo.availability)
+ assertThat(available!![workUser]).isTrue()
+
+ repo.requestState(workUser, false)
+ assertThat(available!![workUser]).isFalse()
+
+ repo.requestState(workUser, true)
+ assertThat(available!![workUser]).isTrue()
+ }
+
+ @Test
+ fun isAvailable_addRemove() = runTest {
+ val repo = FakeUserRepository(listOf(personalUser, workUser))
+
+ val available by collectLastValue(repo.availability)
+ assertThat(available!![workUser]).isTrue()
+
+ repo.removeUser(workUser)
+ assertThat(available!![workUser]).isNull()
+
+ repo.addUser(workUser, true)
+ assertThat(available!![workUser]).isTrue()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt
new file mode 100644
index 00000000..8db0bb56
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.data.repository
+
+import android.content.pm.UserInfo
+import android.os.UserHandle
+import android.os.UserHandle.SYSTEM
+import android.os.UserHandle.USER_SYSTEM
+import android.os.UserManager
+import com.android.intentresolver.coroutines.collectLastValue
+import com.android.intentresolver.platform.FakeUserManager
+import com.android.intentresolver.platform.FakeUserManager.ProfileType
+import com.android.intentresolver.shared.model.User
+import com.android.intentresolver.shared.model.User.Role
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+internal class UserRepositoryImplTest {
+ private val userManager = FakeUserManager()
+ private val userState = userManager.state
+
+ @Test
+ fun initialization() = runTest {
+ val repo = createUserRepository(userManager)
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users)
+ .containsExactly(User(userState.primaryUserHandle.identifier, Role.PERSONAL))
+ }
+
+ @Test
+ fun createProfile() = runTest {
+ val repo = createUserRepository(userManager)
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users).hasSize(1)
+
+ val profile = userState.createProfile(ProfileType.WORK)
+ assertThat(users).hasSize(2)
+ assertThat(users).contains(User(profile.identifier, Role.WORK))
+ }
+
+ @Test
+ fun removeProfile() = runTest {
+ val repo = createUserRepository(userManager)
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ val work = userState.createProfile(ProfileType.WORK)
+ assertThat(users).contains(User(work.identifier, Role.WORK))
+
+ userState.removeProfile(work)
+ assertThat(users).doesNotContain(User(work.identifier, Role.WORK))
+ }
+
+ @Test
+ fun isAvailable() = runTest {
+ val repo = createUserRepository(userManager)
+ val work = userState.createProfile(ProfileType.WORK)
+ val workUser = User(work.identifier, Role.WORK)
+
+ val available by collectLastValue(repo.availability)
+ assertThat(available?.get(workUser)).isTrue()
+
+ userState.setQuietMode(work, true)
+ assertThat(available?.get(workUser)).isFalse()
+
+ userState.setQuietMode(work, false)
+ assertThat(available?.get(workUser)).isTrue()
+ }
+
+ @Test
+ fun onHandleAvailabilityChange_userStateMaintained() = runTest {
+ val repo = createUserRepository(userManager)
+ val private = userState.createProfile(ProfileType.PRIVATE)
+ val privateUser = User(private.identifier, Role.PRIVATE)
+
+ val users by collectLastValue(repo.users)
+
+ repo.requestState(privateUser, false)
+ repo.requestState(privateUser, true)
+
+ assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private
+
+ assertWithMessage("No duplicate IDs")
+ .that(users?.count { it.id == private.identifier })
+ .isEqualTo(1)
+ }
+
+ @Test
+ fun requestState() = runTest {
+ val repo = createUserRepository(userManager)
+ val work = userState.createProfile(ProfileType.WORK)
+ val workUser = User(work.identifier, Role.WORK)
+
+ val available by collectLastValue(repo.availability)
+ assertThat(available?.get(workUser)).isTrue()
+
+ repo.requestState(workUser, false)
+ assertThat(available?.get(workUser)).isFalse()
+
+ repo.requestState(workUser, true)
+ assertThat(available?.get(workUser)).isTrue()
+ }
+
+ /**
+ * This and all the 'recovers_from_*' tests below all configure a static event flow instead of
+ * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to
+ * reinitialize with the user profile group.
+ */
+ @Test
+ fun recovers_from_invalid_profile_added_event() = runTest {
+ val userManager =
+ mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
+ val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL)))
+ val repo =
+ UserRepositoryImpl(
+ profileParent = SYSTEM,
+ userManager = userManager,
+ userEvents = events,
+ scope = backgroundScope,
+ backgroundDispatcher = Dispatchers.Unconfined
+ )
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL))
+ }
+
+ @Test
+ fun recovers_from_invalid_profile_removed_event() = runTest {
+ val userManager =
+ mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
+ val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL)))
+ val repo =
+ UserRepositoryImpl(
+ profileParent = SYSTEM,
+ userManager = userManager,
+ userEvents = events,
+ scope = backgroundScope,
+ backgroundDispatcher = Dispatchers.Unconfined
+ )
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL))
+ }
+
+ @Test
+ fun recovers_from_invalid_profile_available_event() = runTest {
+ val userManager =
+ mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
+ val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL)))
+ val repo =
+ UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined)
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL))
+ }
+
+ @Test
+ fun recovers_from_unknown_event() = runTest {
+ val userManager =
+ mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL)
+ val events = flowOf(UnknownEvent("UNKNOWN_EVENT"))
+ val repo =
+ UserRepositoryImpl(
+ profileParent = SYSTEM,
+ userManager = userManager,
+ userEvents = events,
+ scope = backgroundScope,
+ backgroundDispatcher = Dispatchers.Unconfined
+ )
+ val users by collectLastValue(repo.users)
+
+ assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull()
+ assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL))
+ }
+}
+
+@Suppress("SameParameterValue")
+private fun mockUserManager(validUser: Int, invalidUser: Int) =
+ mock<UserManager> {
+ val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL)
+ on { getEnabledProfiles(any()) } doReturn listOf(info)
+ on { getUserInfo(validUser) } doReturn info
+ on { getEnabledProfiles(invalidUser) } doReturn listOf()
+ on { getUserInfo(invalidUser) } doReturn null
+ }
+
+private fun TestScope.createUserRepository(userManager: FakeUserManager): UserRepositoryImpl {
+ return UserRepositoryImpl(
+ profileParent = userManager.state.primaryUserHandle,
+ userManager = userManager,
+ userEvents = userManager.state.userEvents,
+ scope = backgroundScope,
+ backgroundDispatcher = Dispatchers.Unconfined
+ )
+}
diff --git a/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt
new file mode 100644
index 00000000..4d6f2e5b
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.domain.interactor
+
+import com.android.intentresolver.coroutines.collectLastValue
+import com.android.intentresolver.data.repository.FakeUserRepository
+import com.android.intentresolver.shared.model.Profile
+import com.android.intentresolver.shared.model.Profile.Type.PERSONAL
+import com.android.intentresolver.shared.model.Profile.Type.PRIVATE
+import com.android.intentresolver.shared.model.Profile.Type.WORK
+import com.android.intentresolver.shared.model.User
+import com.android.intentresolver.shared.model.User.Role
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlin.random.Random
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class UserInteractorTest {
+ private val baseId = Random.nextInt(1000, 2000)
+
+ private val personalUser = User(id = baseId, role = Role.PERSONAL)
+ private val cloneUser = User(id = baseId + 1, role = Role.CLONE)
+ private val workUser = User(id = baseId + 2, role = Role.WORK)
+ private val privateUser = User(id = baseId + 3, role = Role.PRIVATE)
+
+ val personalProfile = Profile(PERSONAL, personalUser)
+ val workProfile = Profile(WORK, workUser)
+ val privateProfile = Profile(PRIVATE, privateUser)
+
+ @Test
+ fun launchedByProfile(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository = FakeUserRepository(listOf(personalUser, cloneUser)),
+ launchedAs = personalUser.handle
+ )
+
+ val launchedAsProfile by collectLastValue(profileInteractor.launchedAsProfile)
+
+ assertThat(launchedAsProfile).isEqualTo(Profile(PERSONAL, personalUser, cloneUser))
+ }
+
+ @Test
+ fun launchedByProfile_asClone(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository = FakeUserRepository(listOf(personalUser, cloneUser)),
+ launchedAs = cloneUser.handle
+ )
+ val profiles by collectLastValue(profileInteractor.launchedAsProfile)
+
+ assertThat(profiles).isEqualTo(Profile(PERSONAL, personalUser, cloneUser))
+ }
+
+ @Test
+ fun profiles_withPersonal(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository = FakeUserRepository(listOf(personalUser)),
+ launchedAs = personalUser.handle
+ )
+
+ val profiles by collectLastValue(profileInteractor.profiles)
+
+ assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser))
+ }
+
+ @Test
+ fun profiles_addClone(): Unit = runTest {
+ val fakeUserRepo = FakeUserRepository(listOf(personalUser))
+ val profileInteractor =
+ UserInteractor(userRepository = fakeUserRepo, launchedAs = personalUser.handle)
+
+ val profiles by collectLastValue(profileInteractor.profiles)
+ assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser))
+
+ fakeUserRepo.addUser(cloneUser, available = true)
+ assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser))
+ }
+
+ @Test
+ fun profiles_withPersonalAndClone(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository = FakeUserRepository(listOf(personalUser, cloneUser)),
+ launchedAs = personalUser.handle
+ )
+ val profiles by collectLastValue(profileInteractor.profiles)
+
+ assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser))
+ }
+
+ @Test
+ fun profiles_withAllSupportedTypes(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository =
+ FakeUserRepository(listOf(personalUser, cloneUser, workUser, privateUser)),
+ launchedAs = personalUser.handle
+ )
+ val profiles by collectLastValue(profileInteractor.profiles)
+
+ assertThat(profiles)
+ .containsExactly(
+ Profile(PERSONAL, personalUser, cloneUser),
+ Profile(WORK, workUser),
+ Profile(PRIVATE, privateUser)
+ )
+ }
+
+ @Test
+ fun profiles_preservesIterationOrder(): Unit = runTest {
+ val profileInteractor =
+ UserInteractor(
+ userRepository =
+ FakeUserRepository(listOf(workUser, cloneUser, privateUser, personalUser)),
+ launchedAs = personalUser.handle
+ )
+
+ val profiles by collectLastValue(profileInteractor.profiles)
+
+ assertThat(profiles)
+ .containsExactly(
+ Profile(WORK, workUser),
+ Profile(PRIVATE, privateUser),
+ Profile(PERSONAL, personalUser, cloneUser),
+ )
+ }
+
+ @Test
+ fun isAvailable_defaultValue() = runTest {
+ val userRepo = FakeUserRepository(listOf(personalUser))
+ userRepo.addUser(workUser, false)
+
+ val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle)
+
+ val availability by collectLastValue(interactor.availability)
+
+ assertWithMessage("personalAvailable").that(availability?.get(personalProfile)).isTrue()
+ assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse()
+ }
+
+ @Test
+ fun isAvailable() = runTest {
+ val userRepo = FakeUserRepository(listOf(workUser, personalUser))
+ val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle)
+
+ val availability by collectLastValue(interactor.availability)
+
+ // Default state is enabled in FakeUserManager
+ assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue()
+
+ // Making user unavailable makes profile unavailable
+ userRepo.requestState(workUser, false)
+ assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse()
+
+ // Making user available makes profile available again
+ userRepo.requestState(workUser, true)
+ assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue()
+
+ // When a user is removed availability is removed as well.
+ userRepo.removeUser(workUser)
+ assertWithMessage("workAvailable").that(availability?.get(workProfile)).isNull()
+ }
+
+ /**
+ * Similar to the above test in reverse: uses UserInteractor to modify state, and verify the
+ * state of the UserRepository.
+ */
+ @Test
+ fun updateState() = runTest {
+ val userRepo = FakeUserRepository(listOf(workUser, personalUser))
+ val userInteractor =
+ UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle)
+ val workProfile = Profile(Profile.Type.WORK, workUser)
+
+ val availability by collectLastValue(userRepo.availability)
+
+ // Default state is enabled in FakeUserManager
+ assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue()
+
+ userInteractor.updateState(workProfile, false)
+ assertWithMessage("workAvailable").that(availability?.get(workUser)).isFalse()
+
+ userInteractor.updateState(workProfile, true)
+ assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt
new file mode 100644
index 00000000..084d12e6
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.intentresolver.emptystate
+
+import com.android.intentresolver.ResolverListAdapter
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.mockito.kotlin.mock
+
+class CompositeEmptyStateProviderTest {
+ val listAdapter = mock<ResolverListAdapter>()
+
+ val emptyState1 = object : EmptyState {}
+ val emptyState2 = object : EmptyState {}
+
+ val positiveEmptyStateProvider1 =
+ object : EmptyStateProvider {
+ override fun getEmptyState(listAdapter: ResolverListAdapter) = emptyState1
+ }
+ val positiveEmptyStateProvider2 =
+ object : EmptyStateProvider {
+ override fun getEmptyState(listAdapter: ResolverListAdapter) = emptyState2
+ }
+ val nullEmptyStateProvider =
+ object : EmptyStateProvider {
+ override fun getEmptyState(listAdapter: ResolverListAdapter) = null
+ }
+
+ @Test
+ fun testComposedProvider_returnsFirstEmptyStateInOrder() {
+ val provider =
+ CompositeEmptyStateProvider(
+ nullEmptyStateProvider,
+ positiveEmptyStateProvider1,
+ positiveEmptyStateProvider2
+ )
+ assertThat(provider.getEmptyState(listAdapter)).isSameInstanceAs(emptyState1)
+ }
+
+ @Test
+ fun testComposedProvider_allProvidersReturnNull_composedResultIsNull() {
+ val provider = CompositeEmptyStateProvider(nullEmptyStateProvider)
+ assertThat(provider.getEmptyState(listAdapter)).isNull()
+ }
+
+ @Test
+ fun testComposedProvider_noEmptyStateIfNoDelegateProviders() {
+ val provider = CompositeEmptyStateProvider()
+ assertThat(provider.getEmptyState(listAdapter)).isNull()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt
new file mode 100644
index 00000000..8cf87ebe
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.intentresolver.emptystate
+
+import android.content.ContentResolver
+import android.content.Intent
+import android.content.pm.IPackageManager
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.mockito.Mockito.any
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.nullable
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+class CrossProfileIntentsCheckerTest {
+ private val PERSONAL_USER_ID = 10
+ private val WORK_USER_ID = 20
+
+ private val contentResolver = mock<ContentResolver>()
+
+ @Test
+ fun testChecker_hasCrossProfileIntents() {
+ val packageManager =
+ mock<IPackageManager> {
+ on {
+ canForwardTo(
+ any(Intent::class.java),
+ nullable(String::class.java),
+ eq(PERSONAL_USER_ID),
+ eq(WORK_USER_ID)
+ )
+ } doReturn (true)
+ }
+ val checker = CrossProfileIntentsChecker(contentResolver, packageManager)
+ val intents = listOf(Intent())
+ assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID)).isTrue()
+ }
+
+ @Test
+ fun testChecker_noCrossProfileIntents() {
+ val packageManager =
+ mock<IPackageManager> {
+ on {
+ canForwardTo(
+ any(Intent::class.java),
+ nullable(String::class.java),
+ anyInt(),
+ anyInt()
+ )
+ } doReturn (false)
+ }
+ val checker = CrossProfileIntentsChecker(contentResolver, packageManager)
+ val intents = listOf(Intent())
+ assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID))
+ .isFalse()
+ }
+
+ @Test
+ fun testChecker_noIntents() {
+ val packageManager = mock<IPackageManager>()
+ val checker = CrossProfileIntentsChecker(contentResolver, packageManager)
+ val intents = listOf<Intent>()
+ assertThat(checker.hasCrossProfileIntents(intents, PERSONAL_USER_ID, WORK_USER_ID))
+ .isFalse()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt
new file mode 100644
index 00000000..174b8d59
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt
@@ -0,0 +1,227 @@
+/*
+ * 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.intentresolver.emptystate
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import java.util.Optional
+import java.util.function.Supplier
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+
+class EmptyStateUiHelperTest {
+ private val context = InstrumentationRegistry.getInstrumentation().getContext()
+
+ var shouldOverrideContainerPadding = false
+ val containerPaddingSupplier =
+ Supplier<Optional<Int>> {
+ Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null)
+ }
+
+ lateinit var rootContainer: ViewGroup
+ lateinit var mainListView: View // Visible when no empty state is showing.
+ lateinit var emptyStateTitleView: TextView
+ lateinit var emptyStateSubtitleView: TextView
+ lateinit var emptyStateButtonView: View
+ lateinit var emptyStateProgressView: View
+ lateinit var emptyStateDefaultTextView: View
+ lateinit var emptyStateContainerView: View
+ lateinit var emptyStateRootView: View
+ lateinit var emptyStateUiHelper: EmptyStateUiHelper
+
+ @Before
+ fun setup() {
+ rootContainer = FrameLayout(context)
+ LayoutInflater.from(context)
+ .inflate(
+ com.android.intentresolver.R.layout.resolver_list_per_profile,
+ rootContainer,
+ true
+ )
+ mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list)
+ emptyStateRootView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state)
+ emptyStateTitleView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title)
+ emptyStateSubtitleView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle)
+ emptyStateButtonView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button)
+ emptyStateProgressView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress)
+ emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty)
+ emptyStateContainerView =
+ rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container)
+ emptyStateUiHelper =
+ EmptyStateUiHelper(
+ rootContainer,
+ com.android.internal.R.id.resolver_list,
+ containerPaddingSupplier
+ )
+ }
+
+ @Test
+ fun testResetViewVisibilities() {
+ // First set each view's visibility to differ from the expected "reset" state so we can then
+ // assert that they're all reset afterward.
+ // TODO: for historic reasons "reset" doesn't cover `emptyStateContainerView`; should it?
+ emptyStateRootView.visibility = View.GONE
+ emptyStateTitleView.visibility = View.GONE
+ emptyStateSubtitleView.visibility = View.GONE
+ emptyStateButtonView.visibility = View.VISIBLE
+ emptyStateProgressView.visibility = View.VISIBLE
+ emptyStateDefaultTextView.visibility = View.VISIBLE
+
+ emptyStateUiHelper.resetViewVisibilities()
+
+ assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE)
+ assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE)
+ }
+
+ @Test
+ fun testShowSpinner() {
+ emptyStateTitleView.visibility = View.VISIBLE
+ emptyStateButtonView.visibility = View.VISIBLE
+ emptyStateProgressView.visibility = View.GONE
+ emptyStateDefaultTextView.visibility = View.VISIBLE
+
+ emptyStateUiHelper.showSpinner()
+
+ // TODO: should this cover any other views? Subtitle?
+ assertThat(emptyStateTitleView.visibility).isEqualTo(View.INVISIBLE)
+ assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE)
+ assertThat(emptyStateProgressView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE)
+ }
+
+ @Test
+ fun testHide() {
+ emptyStateRootView.visibility = View.VISIBLE
+ mainListView.visibility = View.GONE
+
+ emptyStateUiHelper.hide()
+
+ assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE)
+ assertThat(mainListView.visibility).isEqualTo(View.VISIBLE)
+ }
+
+ @Test
+ fun testBottomPaddingDelegate_default() {
+ shouldOverrideContainerPadding = false
+ emptyStateContainerView.setPadding(1, 2, 3, 4)
+
+ emptyStateUiHelper.setupContainerPadding()
+
+ assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1)
+ assertThat(emptyStateContainerView.paddingTop).isEqualTo(2)
+ assertThat(emptyStateContainerView.paddingRight).isEqualTo(3)
+ assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4)
+ }
+
+ @Test
+ fun testBottomPaddingDelegate_override() {
+ shouldOverrideContainerPadding = true // Set bottom padding to 42.
+ emptyStateContainerView.setPadding(1, 2, 3, 4)
+
+ emptyStateUiHelper.setupContainerPadding()
+
+ assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1)
+ assertThat(emptyStateContainerView.paddingTop).isEqualTo(2)
+ assertThat(emptyStateContainerView.paddingRight).isEqualTo(3)
+ assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42)
+ }
+
+ @Test
+ fun testShowEmptyState_noOnClickHandler() {
+ mainListView.visibility = View.VISIBLE
+
+ // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be
+ // built into the "on-click handler" that's injected to implement the button-press. We won't
+ // display the button without a click "handler," even if it *does* have a `ClickListener`.
+ val clickListener = mock<EmptyState.ClickListener>()
+
+ val emptyState =
+ object : EmptyState {
+ override fun getTitle() = "Test title"
+ override fun getSubtitle() = "Test subtitle"
+
+ override fun getButtonClickListener() = clickListener
+ }
+ emptyStateUiHelper.showEmptyState(emptyState, null)
+
+ assertThat(mainListView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE)
+
+ assertThat(emptyStateTitleView.text).isEqualTo("Test title")
+ assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle")
+
+ verify(clickListener, never()).onClick(any())
+ }
+
+ @Test
+ fun testShowEmptyState_withOnClickHandlerAndClickListener() {
+ mainListView.visibility = View.VISIBLE
+
+ val clickListener = mock<EmptyState.ClickListener>()
+ val onClickHandler = mock<View.OnClickListener>()
+
+ val emptyState =
+ object : EmptyState {
+ override fun getTitle() = "Test title"
+ override fun getSubtitle() = "Test subtitle"
+
+ override fun getButtonClickListener() = clickListener
+ }
+ emptyStateUiHelper.showEmptyState(emptyState, onClickHandler)
+
+ assertThat(mainListView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown.
+ assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE)
+ assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE)
+
+ assertThat(emptyStateTitleView.text).isEqualTo("Test title")
+ assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle")
+
+ emptyStateButtonView.performClick()
+
+ verify(onClickHandler).onClick(emptyStateButtonView)
+ // The test didn't explicitly configure its `OnClickListener` to relay the click event on
+ // to the `EmptyState.ClickListener`, so it still won't have fired here.
+ verify(clickListener, never()).onClick(any())
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt
new file mode 100644
index 00000000..bee13a21
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.emptystate
+
+import android.content.Intent
+import com.android.intentresolver.ProfileHelper
+import com.android.intentresolver.ResolverListAdapter
+import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.data.repository.DevicePolicyResources
+import com.android.intentresolver.data.repository.FakeUserRepository
+import com.android.intentresolver.domain.interactor.UserInteractor
+import com.android.intentresolver.shared.model.User
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.Dispatchers
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.same
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.verification.VerificationMode
+
+@OptIn(JavaInterop::class)
+class NoCrossProfileEmptyStateProviderTest {
+
+ private val personalUser = User(0, User.Role.PERSONAL)
+ private val workUser = User(10, User.Role.WORK)
+ private val privateUser = User(11, User.Role.PRIVATE)
+
+ private val userRepository = FakeUserRepository(listOf(personalUser, workUser, privateUser))
+
+ private val personalIntents = listOf(Intent("PERSONAL"))
+ private val personalListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn personalUser.handle
+ on { intents } doReturn personalIntents
+ }
+ private val workIntents = listOf(Intent("WORK"))
+ private val workListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn workUser.handle
+ on { intents } doReturn workIntents
+ }
+ private val privateIntents = listOf(Intent("PRIVATE"))
+ private val privateListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn privateUser.handle
+ on { intents } doReturn privateIntents
+ }
+
+ private val devicePolicyResources =
+ mock<DevicePolicyResources> {
+ on { crossProfileBlocked } doReturn "Cross profile blocked"
+ on { toPersonalBlockedByPolicyMessage(any()) } doReturn "Blocked to Personal"
+ on { toWorkBlockedByPolicyMessage(any()) } doReturn "Blocked to Work"
+ on { toPrivateBlockedByPolicyMessage(any()) } doReturn "Blocked to Private"
+ }
+
+ // If asked, no intent can ever be forwarded between any pair of users.
+ private val crossProfileIntentsChecker =
+ mock<CrossProfileIntentsChecker> {
+ on {
+ hasCrossProfileIntents(
+ /* intents = */ any(),
+ /* source = */ any(),
+ /* target = */ any(),
+ )
+ } doReturn false /* Never allow */
+ }
+
+ @Test
+ fun verifyTestSetup() {
+ assertThat(workListAdapter.userHandle).isEqualTo(workUser.handle)
+ assertThat(personalListAdapter.userHandle).isEqualTo(personalUser.handle)
+ assertThat(privateListAdapter.userHandle).isEqualTo(privateUser.handle)
+ }
+
+ @Test
+ fun sameProfilePermitted() {
+ val profileHelper = createProfileHelper(launchedAs = workUser)
+
+ val provider =
+ NoCrossProfileEmptyStateProvider(
+ profileHelper,
+ devicePolicyResources,
+ crossProfileIntentsChecker,
+ /* isShare = */ true,
+ )
+
+ // Work to work, not blocked
+ assertThat(provider.getEmptyState(workListAdapter)).isNull()
+
+ crossProfileIntentsChecker.verifyCalled(never())
+ }
+
+ @Test
+ fun testPersonalToWork() {
+ val profileHelper = createProfileHelper(launchedAs = personalUser)
+
+ val provider =
+ NoCrossProfileEmptyStateProvider(
+ profileHelper,
+ devicePolicyResources,
+ crossProfileIntentsChecker,
+ /* isShare = */ true,
+ )
+
+ val result = provider.getEmptyState(workListAdapter)
+ assertThat(result).isNotNull()
+ assertThat(result?.title).isEqualTo("Cross profile blocked")
+ assertThat(result?.subtitle).isEqualTo("Blocked to Work")
+
+ crossProfileIntentsChecker.verifyCalled(times(1), workIntents, personalUser, workUser)
+ }
+
+ @Test
+ fun testWorkToPersonal() {
+ val profileHelper = createProfileHelper(launchedAs = workUser)
+
+ val provider =
+ NoCrossProfileEmptyStateProvider(
+ profileHelper,
+ devicePolicyResources,
+ crossProfileIntentsChecker,
+ /* isShare = */ true,
+ )
+
+ val result = provider.getEmptyState(personalListAdapter)
+ assertThat(result).isNotNull()
+ assertThat(result?.title).isEqualTo("Cross profile blocked")
+ assertThat(result?.subtitle).isEqualTo("Blocked to Personal")
+
+ crossProfileIntentsChecker.verifyCalled(times(1), personalIntents, workUser, personalUser)
+ }
+
+ @Test
+ fun testWorkToPrivate() {
+ val profileHelper = createProfileHelper(launchedAs = workUser)
+
+ val provider =
+ NoCrossProfileEmptyStateProvider(
+ profileHelper,
+ devicePolicyResources,
+ crossProfileIntentsChecker,
+ /* isShare = */ true,
+ )
+
+ val result = provider.getEmptyState(privateListAdapter)
+ assertThat(result).isNotNull()
+ assertThat(result?.title).isEqualTo("Cross profile blocked")
+ assertThat(result?.subtitle).isEqualTo("Blocked to Private")
+
+ // effective target user is personalUser due to "delegate from parent"
+ crossProfileIntentsChecker.verifyCalled(times(1), privateIntents, workUser, personalUser)
+ }
+
+ @Test
+ fun testPrivateToPersonal() {
+ val profileHelper = createProfileHelper(launchedAs = privateUser)
+
+ val provider =
+ NoCrossProfileEmptyStateProvider(
+ profileHelper,
+ devicePolicyResources,
+ crossProfileIntentsChecker,
+ /* isShare = */ true,
+ )
+
+ // Private -> Personal is always allowed:
+ // Private delegates to the parent profile for policy; so personal->personal is allowed.
+ assertThat(provider.getEmptyState(personalListAdapter)).isNull()
+
+ crossProfileIntentsChecker.verifyCalled(never())
+ }
+
+ private fun createProfileHelper(launchedAs: User): ProfileHelper {
+ val userInteractor = UserInteractor(userRepository, launchedAs = launchedAs.handle)
+
+ return ProfileHelper(userInteractor, Dispatchers.Unconfined)
+ }
+
+ private fun CrossProfileIntentsChecker.verifyCalled(
+ mode: VerificationMode,
+ list: List<Intent>? = null,
+ sourceUser: User? = null,
+ targetUser: User? = null,
+ ) {
+ val sourceUserId = argumentCaptor<Int>()
+ val targetUserId = argumentCaptor<Int>()
+
+ verify(this, mode)
+ .hasCrossProfileIntents(same(list), sourceUserId.capture(), targetUserId.capture())
+ sourceUser?.apply {
+ assertWithMessage("hasCrossProfileIntents: source")
+ .that(sourceUserId.firstValue)
+ .isEqualTo(id)
+ }
+ targetUser?.apply {
+ assertWithMessage("hasCrossProfileIntents: target")
+ .that(targetUserId.firstValue)
+ .isEqualTo(id)
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt
new file mode 100644
index 00000000..dbaee3d0
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.graphics.Point
+import androidx.core.os.bundleOf
+import androidx.lifecycle.DEFAULT_ARGS_KEY
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.lifecycle.viewmodel.MutableCreationExtras
+import androidx.test.ext.truth.os.BundleSubject.assertThat
+import org.junit.Test
+
+class CreationExtrasExtTest {
+ @Test
+ fun addDefaultArgs_addsWhenAbsent() {
+ val creationExtras: CreationExtras = MutableCreationExtras() // empty
+
+ val updated = creationExtras.addDefaultArgs("POINT" to Point(1, 1))
+
+ val defaultArgs = updated[DEFAULT_ARGS_KEY]
+ assertThat(defaultArgs).containsKey("POINT")
+ assertThat(defaultArgs).parcelable<Point>("POINT").marshallsEquallyTo(Point(1, 1))
+ }
+
+ @Test
+ fun addDefaultArgs_addsToExisting() {
+ val creationExtras: CreationExtras =
+ MutableCreationExtras().apply {
+ set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1)))
+ }
+
+ val updated = creationExtras.addDefaultArgs("POINT2" to Point(2, 2))
+
+ val defaultArgs = updated[DEFAULT_ARGS_KEY]
+ assertThat(defaultArgs).containsKey("POINT1")
+ assertThat(defaultArgs).containsKey("POINT2")
+ assertThat(defaultArgs).parcelable<Point>("POINT1").marshallsEquallyTo(Point(1, 1))
+ assertThat(defaultArgs).parcelable<Point>("POINT2").marshallsEquallyTo(Point(2, 2))
+ }
+
+ @Test
+ fun replaceDefaultArgs_replacesExisting() {
+ val creationExtras: CreationExtras =
+ MutableCreationExtras().apply {
+ set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1)))
+ }
+
+ val updated = creationExtras.replaceDefaultArgs("POINT2" to Point(2, 2))
+
+ val defaultArgs = updated[DEFAULT_ARGS_KEY]
+ assertThat(defaultArgs).doesNotContainKey("POINT1")
+ assertThat(defaultArgs).containsKey("POINT2")
+ assertThat(defaultArgs).parcelable<Point>("POINT2").marshallsEquallyTo(Point(2, 2))
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt
new file mode 100644
index 00000000..bf1e159c
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ext
+
+import android.content.ComponentName
+import android.content.Intent
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.function.Predicate
+import org.junit.Test
+
+class IntentExtTest {
+
+ private val hasSendAction =
+ Predicate<Intent> {
+ it?.action == Intent.ACTION_SEND || it?.action == Intent.ACTION_SEND_MULTIPLE
+ }
+
+ @Test
+ fun hasAction() {
+ val sendIntent = Intent(Intent.ACTION_SEND)
+ assertThat(sendIntent.hasAction(Intent.ACTION_SEND)).isTrue()
+ assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse()
+ }
+
+ @Test
+ fun hasComponent() {
+ assertThat(Intent().hasComponent()).isFalse()
+ assertThat(Intent().setComponent(ComponentName("A", "B")).hasComponent()).isTrue()
+ }
+
+ @Test
+ fun hasSendAction() {
+ assertThat(Intent(Intent.ACTION_SEND).hasSendAction()).isTrue()
+ assertThat(Intent(Intent.ACTION_SEND_MULTIPLE).hasSendAction()).isTrue()
+ assertThat(Intent(Intent.ACTION_SENDTO).hasSendAction()).isFalse()
+ assertThat(Intent(Intent.ACTION_VIEW).hasSendAction()).isFalse()
+ }
+
+ @Test
+ fun hasSingleCategory() {
+ val intent = Intent().addCategory(Intent.CATEGORY_HOME)
+ assertThat(intent.hasSingleCategory(Intent.CATEGORY_HOME)).isTrue()
+ assertThat(intent.hasSingleCategory(Intent.CATEGORY_DEFAULT)).isFalse()
+
+ intent.addCategory(Intent.CATEGORY_TEST)
+ assertThat(intent.hasSingleCategory(Intent.CATEGORY_TEST)).isFalse()
+ }
+
+ @Test
+ fun ifMatch_matched() {
+ val sendIntent = Intent(Intent.ACTION_SEND)
+ val sendMultipleIntent = Intent(Intent.ACTION_SEND_MULTIPLE)
+
+ sendIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) }
+ sendMultipleIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) }
+ assertWithMessage("sendIntent flags")
+ .that(sendIntent.flags)
+ .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL)
+ assertWithMessage("sendMultipleIntent flags")
+ .that(sendMultipleIntent.flags)
+ .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL)
+ }
+
+ @Test
+ fun ifMatch_notMatched() {
+ val viewIntent = Intent(Intent.ACTION_VIEW)
+
+ viewIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) }
+ assertWithMessage("viewIntent flags").that(viewIntent.flags).isEqualTo(0)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt
new file mode 100644
index 00000000..2f0ed423
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2024 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.intentresolver.icons
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+import android.os.UserHandle
+import com.android.intentresolver.ResolverDataProvider.createResolveInfo
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.chooser.SelectableTargetInfo
+import com.android.intentresolver.chooser.TargetInfo
+import java.util.function.Consumer
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+class CachingTargetDataLoaderTest {
+ private val context = mock<Context>()
+ private val userHandle = UserHandle.of(1)
+
+ @Test
+ fun doNotCacheCallerProvidedShortcuts() {
+ val callerTarget =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ /* sourceInfo = */ null,
+ /* backupResolveInfo = */ null,
+ /* resolvedIntent = */ Intent(),
+ /* chooserTargetComponentName =*/ ComponentName("package", "Activity"),
+ "chooserTargetUninitializedTitle",
+ /* chooserTargetIcon =*/ Icon.createWithContentUri("content://package/icon.png"),
+ /* chooserTargetIntentExtras =*/ null,
+ /* modifiedScore =*/ 1f,
+ /* shortcutInfo = */ null,
+ /* appTarget = */ null,
+ /* referrerFillInIntent = */ Intent(),
+ ) as SelectableTargetInfo
+
+ val targetDataLoader =
+ mock<TargetDataLoader> {
+ on { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } doReturn
+ null
+ }
+ val testSubject = CachingTargetDataLoader(context, targetDataLoader)
+ val callback = Consumer<Drawable> {}
+
+ testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback)
+ testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback)
+
+ verify(targetDataLoader) {
+ 2 * { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) }
+ }
+ }
+
+ @Test
+ fun serviceShortcutsAreCached() {
+ val context =
+ mock<Context> {
+ on { userId } doReturn 1
+ on { packageName } doReturn "package"
+ }
+ val targetInfo =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ /* sourceInfo = */ null,
+ /* backupResolveInfo = */ null,
+ /* resolvedIntent = */ Intent(),
+ /* chooserTargetComponentName =*/ ComponentName("package", "Activity"),
+ "chooserTargetUninitializedTitle",
+ /* chooserTargetIcon =*/ null,
+ /* chooserTargetIntentExtras =*/ null,
+ /* modifiedScore =*/ 1f,
+ /* shortcutInfo = */ ShortcutInfo.Builder(context, "1").build(),
+ /* appTarget = */ null,
+ /* referrerFillInIntent = */ Intent(),
+ ) as SelectableTargetInfo
+
+ val targetDataLoader = mock<TargetDataLoader>()
+ doAnswer {
+ val callback = it.arguments[2] as Consumer<Drawable>
+ callback.accept(BitmapDrawable(createBitmap()))
+ null
+ }
+ .whenever(targetDataLoader)
+ .getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any())
+ val testSubject = CachingTargetDataLoader(context, targetDataLoader)
+ val callback = Consumer<Drawable> {}
+
+ testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback)
+ testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback)
+
+ verify(targetDataLoader) {
+ 1 * { getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) }
+ }
+ }
+
+ @Test
+ fun onlyBitmapsAreCached() {
+ val context =
+ mock<Context> {
+ on { userId } doReturn 1
+ on { packageName } doReturn "package"
+ }
+ val colorTargetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ createResolveInfo(1, userHandle.identifier),
+ Intent(),
+ ) as DisplayResolveInfo
+ val bitmapTargetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ createResolveInfo(2, userHandle.identifier),
+ Intent(),
+ ) as DisplayResolveInfo
+ val hoverBitmapTargetInfo =
+ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ createResolveInfo(3, userHandle.identifier),
+ Intent(),
+ ) as DisplayResolveInfo
+
+ val targetDataLoader = mock<TargetDataLoader>()
+ doAnswer {
+ val target = it.arguments[0] as TargetInfo
+ val callback = it.arguments[2] as Consumer<Drawable>
+ val drawable =
+ if (target === bitmapTargetInfo) {
+ BitmapDrawable(createBitmap())
+ } else if (target === hoverBitmapTargetInfo) {
+ HoverBitmapDrawable(createBitmap())
+ } else {
+ ColorDrawable(Color.RED)
+ }
+ callback.accept(drawable)
+ null
+ }
+ .whenever(targetDataLoader)
+ .getOrLoadAppTargetIcon(any(), eq(userHandle), any())
+ val testSubject = CachingTargetDataLoader(context, targetDataLoader)
+ val callback = Consumer<Drawable> {}
+
+ testSubject.getOrLoadAppTargetIcon(colorTargetInfo, userHandle, callback)
+ testSubject.getOrLoadAppTargetIcon(colorTargetInfo, userHandle, callback)
+ testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback)
+ testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback)
+ testSubject.getOrLoadAppTargetIcon(hoverBitmapTargetInfo, userHandle, callback)
+ testSubject.getOrLoadAppTargetIcon(hoverBitmapTargetInfo, userHandle, callback)
+
+ verify(targetDataLoader) {
+ 2 * { getOrLoadAppTargetIcon(eq(colorTargetInfo), eq(userHandle), any()) }
+ }
+ verify(targetDataLoader) {
+ 1 * { getOrLoadAppTargetIcon(eq(bitmapTargetInfo), eq(userHandle), any()) }
+ }
+ verify(targetDataLoader) {
+ 1 * { getOrLoadAppTargetIcon(eq(hoverBitmapTargetInfo), eq(userHandle), any()) }
+ }
+ }
+}
+
+private fun createBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
diff --git a/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt
new file mode 100644
index 00000000..75d4ec0d
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt
@@ -0,0 +1,420 @@
+/*
+ * Copyright 2024 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
+ *
+ * https://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.intentresolver.interactive.domain.interactor
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.ACTION_QUICK_VIEW
+import android.content.Intent.ACTION_RUN
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_VIEW
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INITIAL_INTENTS
+import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS
+import android.content.IntentSender
+import android.os.Binder
+import android.os.IBinder
+import android.os.IBinder.DeathRecipient
+import android.os.IInterface
+import android.os.Parcel
+import android.os.ResultReceiver
+import android.os.ShellCallback
+import android.service.chooser.ChooserTarget
+import androidx.core.os.bundleOf
+import androidx.lifecycle.SavedStateHandle
+import com.android.intentresolver.IChooserController
+import com.android.intentresolver.IChooserInteractiveSessionCallback
+import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.data.repository.ActivityModelRepository
+import com.android.intentresolver.data.repository.ChooserRequestRepository
+import com.android.intentresolver.interactive.data.repository.InteractiveSessionCallbackRepository
+import com.android.intentresolver.shared.model.ActivityModel
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import java.io.FileDescriptor
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class InteractiveSessionInteractorTest {
+ private val activityModelRepo =
+ ActivityModelRepository().apply {
+ initialize {
+ ActivityModel(
+ intent = Intent(),
+ launchedFromUid = 12345,
+ launchedFromPackage = "org.client.package",
+ referrer = null,
+ isTaskRoot = false,
+ )
+ }
+ }
+ private val interactiveSessionCallback = FakeChooserInteractiveSessionCallback()
+ private val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository()
+ private val savedStateHandle = SavedStateHandle()
+ private val interactiveCallbackRepo = InteractiveSessionCallbackRepository(savedStateHandle)
+
+ @Test
+ fun testChooserLaunchedInNewTask_sessionClosed() = runTest {
+ val activityModelRepo =
+ ActivityModelRepository().apply {
+ initialize {
+ ActivityModel(
+ intent = Intent(),
+ launchedFromUid = 12345,
+ launchedFromPackage = "org.client.package",
+ referrer = null,
+ isTaskRoot = true,
+ )
+ }
+ }
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ testSubject.activate()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).containsExactly(null)
+ }
+
+ @Test
+ fun testDeadBinder_sessionEnd() = runTest {
+ interactiveSessionCallback.isAlive = false
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ this.testScheduler.runCurrent()
+
+ assertThat(testSubject.isSessionActive.value).isFalse()
+ }
+
+ @Test
+ fun testBinderDies_sessionEnd() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ this.testScheduler.runCurrent()
+
+ assertThat(testSubject.isSessionActive.value).isTrue()
+ assertThat(interactiveSessionCallback.linkedDeathRecipients).hasSize(1)
+
+ interactiveSessionCallback.linkedDeathRecipients[0].binderDied()
+
+ assertThat(testSubject.isSessionActive.value).isFalse()
+ }
+
+ @Test
+ fun testScopeCancelled_unsubscribeFromBinder() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ val job = backgroundScope.launch { testSubject.activate() }
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.linkedDeathRecipients).hasSize(1)
+ assertThat(interactiveSessionCallback.unlinkedDeathRecipients).hasSize(0)
+
+ job.cancel()
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.unlinkedDeathRecipients).hasSize(1)
+ }
+
+ @Test
+ fun endSession_intentUpdaterCallbackReset() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1)
+
+ testSubject.endSession()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(2)
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters[1]).isNull()
+ }
+
+ @Test
+ fun nullChooserIntentReceived_sessionEnds() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1)
+ interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent(null)
+ testScheduler.runCurrent()
+
+ assertThat(testSubject.isSessionActive.value).isFalse()
+ }
+
+ @Test
+ fun invalidChooserIntentReceived_intentIgnored() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1)
+ interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent(Intent())
+ testScheduler.runCurrent()
+
+ assertThat(testSubject.isSessionActive.value).isTrue()
+ assertThat(chooserRequestRepository.chooserRequest.value)
+ .isEqualTo(chooserRequestRepository.initialRequest)
+ }
+
+ @Test
+ fun validChooserIntentReceived_chooserRequestUpdated() = runTest {
+ val chooserRequestRepository =
+ ChooserRequestRepository(
+ initialRequest =
+ ChooserRequest(
+ targetIntent = Intent(ACTION_SEND),
+ interactiveSessionCallback = interactiveSessionCallback,
+ launchedFromPackage = activityModelRepo.value.launchedFromPackage,
+ ),
+ initialActions = emptyList(),
+ )
+ val testSubject =
+ InteractiveSessionInteractor(
+ activityModelRepo = activityModelRepo,
+ chooserRequestRepository = chooserRequestRepository,
+ pendingSelectionCallbackRepo,
+ interactiveCallbackRepo,
+ )
+
+ backgroundScope.launch { testSubject.activate() }
+ testScheduler.runCurrent()
+
+ assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1)
+ val newTargetIntent = Intent(ACTION_VIEW).apply { type = "image/png" }
+ val newFilteredComponents = arrayOf(ComponentName.unflattenFromString("com.app/.MainA"))
+ val newCallerTargets =
+ arrayOf(
+ ChooserTarget(
+ "A",
+ null,
+ 0.5f,
+ ComponentName.unflattenFromString("org.pkg/.Activity"),
+ null,
+ )
+ )
+ val newAdditionalIntents = arrayOf(Intent(ACTION_RUN))
+ val newReplacementExtras = bundleOf("ONE" to 1, "TWO" to 2)
+ val newInitialIntents = arrayOf(Intent(ACTION_QUICK_VIEW))
+ val newResultSender = IntentSender(Binder())
+ val newRefinementSender = IntentSender(Binder())
+ interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent(
+ Intent.createChooser(newTargetIntent, "").apply {
+ putExtra(EXTRA_EXCLUDE_COMPONENTS, newFilteredComponents)
+ putExtra(EXTRA_CHOOSER_TARGETS, newCallerTargets)
+ putExtra(EXTRA_ALTERNATE_INTENTS, newAdditionalIntents)
+ putExtra(EXTRA_REPLACEMENT_EXTRAS, newReplacementExtras)
+ putExtra(EXTRA_INITIAL_INTENTS, newInitialIntents)
+ putExtra(EXTRA_CHOOSER_RESULT_INTENT_SENDER, newResultSender)
+ putExtra(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, newRefinementSender)
+ }
+ )
+ testScheduler.runCurrent()
+
+ assertThat(testSubject.isSessionActive.value).isTrue()
+ val updatedRequest = chooserRequestRepository.chooserRequest.value
+ assertThat(updatedRequest.targetAction).isEqualTo(newTargetIntent.action)
+ assertThat(updatedRequest.targetType).isEqualTo(newTargetIntent.type)
+ assertThat(updatedRequest.filteredComponentNames).containsExactly(newFilteredComponents[0])
+ assertThat(updatedRequest.callerChooserTargets).containsExactly(newCallerTargets[0])
+ assertThat(updatedRequest.additionalTargets)
+ .comparingElementsUsing<Intent, String>(
+ Correspondence.transforming({ it.action }, "action")
+ )
+ .containsExactly(newAdditionalIntents[0].action)
+ assertThat(updatedRequest.replacementExtras!!.keySet())
+ .containsExactlyElementsIn(newReplacementExtras.keySet())
+ assertThat(updatedRequest.initialIntents)
+ .comparingElementsUsing<Intent, String>(
+ Correspondence.transforming({ it.action }, "action")
+ )
+ .containsExactly(newInitialIntents[0].action)
+ assertThat(updatedRequest.chosenComponentSender).isEqualTo(newResultSender)
+ assertThat(updatedRequest.refinementIntentSender).isEqualTo(newRefinementSender)
+ }
+}
+
+private class FakeChooserInteractiveSessionCallback :
+ IChooserInteractiveSessionCallback, IBinder, IInterface {
+ var isAlive = true
+ val registeredIntentUpdaters = ArrayList<IChooserController?>()
+ val linkedDeathRecipients = ArrayList<DeathRecipient>()
+ val unlinkedDeathRecipients = ArrayList<DeathRecipient>()
+
+ override fun registerChooserController(intentUpdater: IChooserController?) {
+ registeredIntentUpdaters.add(intentUpdater)
+ }
+
+ override fun onDrawerVerticalOffsetChanged(offset: Int) {}
+
+ override fun asBinder() = this
+
+ override fun getInterfaceDescriptor() = ""
+
+ override fun pingBinder() = true
+
+ override fun isBinderAlive() = isAlive
+
+ override fun queryLocalInterface(descriptor: String): IInterface =
+ this@FakeChooserInteractiveSessionCallback
+
+ override fun dump(fd: FileDescriptor, args: Array<out String>?) = Unit
+
+ override fun dumpAsync(fd: FileDescriptor, args: Array<out String>?) = Unit
+
+ override fun shellCommand(
+ `in`: FileDescriptor?,
+ out: FileDescriptor?,
+ err: FileDescriptor?,
+ args: Array<out String>,
+ shellCallback: ShellCallback?,
+ resultReceiver: ResultReceiver,
+ ) = Unit
+
+ override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int) = true
+
+ override fun linkToDeath(recipient: DeathRecipient, flags: Int) {
+ linkedDeathRecipients.add(recipient)
+ }
+
+ override fun unlinkToDeath(recipient: DeathRecipient, flags: Int): Boolean {
+ unlinkedDeathRecipients.add(recipient)
+ return true
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/logging/EventLogTest.java b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java
index 17452774..528c4613 100644
--- a/java/tests/src/com/android/intentresolver/logging/EventLogTest.java
+++ b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java
@@ -32,12 +32,12 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
import android.content.Intent;
import android.metrics.LogMaker;
-import com.android.intentresolver.logging.EventLog.FrameworkStatsLogger;
-import com.android.intentresolver.logging.EventLog.SharesheetStandardEvent;
-import com.android.intentresolver.logging.EventLog.SharesheetStartedEvent;
-import com.android.intentresolver.logging.EventLog.SharesheetTargetSelectedEvent;
import com.android.intentresolver.contentpreview.ContentPreviewType;
+import com.android.intentresolver.logging.EventLogImpl.SharesheetStandardEvent;
+import com.android.intentresolver.logging.EventLogImpl.SharesheetStartedEvent;
+import com.android.intentresolver.logging.EventLogImpl.SharesheetTargetSelectedEvent;
import com.android.internal.logging.InstanceId;
+import com.android.internal.logging.InstanceIdSequence;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.logging.UiEventLogger.UiEventEnum;
@@ -53,17 +53,19 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
-public final class EventLogTest {
+public final class EventLogImplTest {
@Mock private UiEventLogger mUiEventLog;
@Mock private FrameworkStatsLogger mFrameworkLog;
@Mock private MetricsLogger mMetricsLogger;
- private EventLog mChooserLogger;
+ private EventLogImpl mChooserLogger;
+
+ private final InstanceIdSequence mSequence = EventLogImpl.newIdSequence();
@Before
public void setUp() {
- //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger);
- mChooserLogger = new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger);
+ mChooserLogger = new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger,
+ mSequence.newInstanceId());
}
@After
@@ -150,8 +152,47 @@ public final class EventLogTest {
}
@Test
+ public void shareStartedWithShareouselAndEnabledReportingFlag_imagePreviewTypeReported() {
+ final String packageName = "com.test.foo";
+ final String mimeType = "text/plain";
+ final int appProvidedDirectTargets = 123;
+ final int appProvidedAppTargets = 456;
+ final boolean workProfile = true;
+ final int previewType = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION;
+ final String intentAction = Intent.ACTION_SENDTO;
+ final int numCustomActions = 3;
+ final boolean modifyShareProvided = true;
+
+ mChooserLogger.logShareStarted(
+ packageName,
+ mimeType,
+ appProvidedDirectTargets,
+ appProvidedAppTargets,
+ workProfile,
+ previewType,
+ intentAction,
+ numCustomActions,
+ modifyShareProvided);
+
+ verify(mFrameworkLog).write(
+ eq(FrameworkStatsLog.SHARESHEET_STARTED),
+ eq(SharesheetStartedEvent.SHARE_STARTED.getId()),
+ eq(packageName),
+ /* instanceId=*/ gt(0),
+ eq(mimeType),
+ eq(appProvidedDirectTargets),
+ eq(appProvidedAppTargets),
+ eq(workProfile),
+ eq(FrameworkStatsLog
+ .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA),
+ eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO),
+ /* custom actions provided */ eq(numCustomActions),
+ /* reselection action provided */ eq(modifyShareProvided));
+ }
+
+ @Test
public void testLogShareTargetSelected() {
- final int targetType = EventLog.SELECTION_TYPE_SERVICE;
+ final int targetType = EventLogImpl.SELECTION_TYPE_SERVICE;
final String packageName = "com.test.foo";
final int positionPicked = 123;
final int directTargetAlsoRanked = -1;
@@ -189,7 +230,7 @@ public final class EventLogTest {
@Test
public void testLogActionSelected() {
- mChooserLogger.logActionSelected(EventLog.SELECTION_TYPE_COPY);
+ mChooserLogger.logActionSelected(EventLogImpl.SELECTION_TYPE_COPY);
verify(mFrameworkLog).write(
eq(FrameworkStatsLog.RANKING_SELECTED),
@@ -320,10 +361,11 @@ public final class EventLogTest {
@Test
public void testDifferentLoggerInstancesUseDifferentInstanceIds() {
ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
- EventLog chooserLogger2 =
- new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger);
+ EventLogImpl chooserLogger2 =
+ new EventLogImpl(mUiEventLog, mFrameworkLog, mMetricsLogger,
+ mSequence.newInstanceId());
- final int targetType = EventLog.SELECTION_TYPE_COPY;
+ final int targetType = EventLogImpl.SELECTION_TYPE_COPY;
final String packageName = "com.test.foo";
final int positionPicked = 123;
final int directTargetAlsoRanked = -1;
@@ -370,7 +412,7 @@ public final class EventLogTest {
ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class);
- final int targetType = EventLog.SELECTION_TYPE_COPY;
+ final int targetType = EventLogImpl.SELECTION_TYPE_COPY;
final String packageName = "com.test.foo";
final int positionPicked = 123;
final int directTargetAlsoRanked = -1;
@@ -403,20 +445,20 @@ public final class EventLogTest {
@Test
public void testTargetSelectionCategories() {
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_SERVICE))
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_SERVICE))
.isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET);
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_APP))
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_APP))
.isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET);
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_STANDARD))
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_STANDARD))
.isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET);
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_COPY)).isEqualTo(0);
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_NEARBY)).isEqualTo(0);
- assertThat(EventLog.getTargetSelectionCategory(
- EventLog.SELECTION_TYPE_EDIT)).isEqualTo(0);
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_COPY)).isEqualTo(0);
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_NEARBY)).isEqualTo(0);
+ assertThat(EventLogImpl.getTargetSelectionCategory(
+ EventLogImpl.SELECTION_TYPE_EDIT)).isEqualTo(0);
}
}
diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
index 5f0ead7b..5cec9734 100644
--- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
+++ b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
@@ -25,7 +25,7 @@ import android.content.pm.ActivityInfo;
import android.content.pm.ResolveInfo;
import android.os.Message;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.platform.app.InstrumentationRegistry;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.chooser.TargetInfo;
@@ -47,7 +47,7 @@ public class AbstractResolverComparatorTest {
ResolvedComponentInfo r2 = createResolvedComponentInfo(
new ComponentName("zackage", "zlass"));
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, null);
assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2));
@@ -64,7 +64,7 @@ public class AbstractResolverComparatorTest {
new ComponentName("zackage", "zlass"));
r2.setPinned(true);
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, null);
assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2));
@@ -78,7 +78,7 @@ public class AbstractResolverComparatorTest {
ResolvedComponentInfo r2 = createResolvedComponentInfo(
new ComponentName("package", "class"));
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst);
assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2));
@@ -94,7 +94,7 @@ public class AbstractResolverComparatorTest {
new ComponentName("package", "class"));
r2.setPinned(true);
- Context context = InstrumentationRegistry.getTargetContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
AbstractResolverComparator comparator = getTestComparator(context, cementedComponent);
assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2));
@@ -118,14 +118,14 @@ public class AbstractResolverComparatorTest {
Lists.newArrayList(context.getUser()), promoteToFirst) {
@Override
- int compare(ResolveInfo lhs, ResolveInfo rhs) {
+ public int compare(ResolveInfo lhs, ResolveInfo rhs) {
// Used for testing pinning, so we should never get here --- the overrides
// should determine the result instead.
return 1;
}
@Override
- void doCompute(List<ResolvedComponentInfo> targets) {}
+ public void doCompute(List<ResolvedComponentInfo> targets) {}
@Override
public float getScore(TargetInfo targetInfo) {
@@ -133,7 +133,7 @@ public class AbstractResolverComparatorTest {
}
@Override
- void handleResultMessage(Message message) {}
+ public void handleResultMessage(Message message) {}
};
return testComparator;
}
diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt
new file mode 100644
index 00000000..82daca55
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/platform/FakeSettingsTest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class FakeSettingsTest {
+
+ private val settings: FakeSettings = fakeSettings {
+ putInt(intKey, intVal)
+ putString(stringKey, stringVal)
+ putFloat(floatKey, floatVal)
+ putLong(longKey, longVal)
+ }
+
+ @Test
+ fun testExpectedValues_returned() {
+ assertThat(settings.getIntOrNull(intKey)).isEqualTo(intVal)
+ assertThat(settings.getStringOrNull(stringKey)).isEqualTo(stringVal)
+ assertThat(settings.getFloatOrNull(floatKey)).isEqualTo(floatVal)
+ assertThat(settings.getLongOrNull(longKey)).isEqualTo(longVal)
+ }
+
+ @Test
+ fun testUndefinedValues_returnNull() {
+ assertThat(settings.getIntOrNull("unknown")).isNull()
+ assertThat(settings.getStringOrNull("unknown")).isNull()
+ assertThat(settings.getFloatOrNull("unknown")).isNull()
+ assertThat(settings.getLongOrNull("unknown")).isNull()
+ }
+
+ /**
+ * FakeSecureSettings models the real secure settings by storing values in String form. The
+ * value is returned if/when it can be parsed from the string value, otherwise null.
+ */
+ @Test
+ fun testMismatchedTypes() {
+ assertThat(settings.getStringOrNull(intKey)).isEqualTo(intVal.toString())
+ assertThat(settings.getStringOrNull(floatKey)).isEqualTo(floatVal.toString())
+ assertThat(settings.getStringOrNull(longKey)).isEqualTo(longVal.toString())
+
+ assertThat(settings.getIntOrNull(stringKey)).isNull()
+ assertThat(settings.getLongOrNull(stringKey)).isNull()
+ assertThat(settings.getFloatOrNull(stringKey)).isNull()
+
+ assertThat(settings.getIntOrNull(longKey)).isNull()
+ assertThat(settings.getFloatOrNull(longKey)).isWithin(0.00001f).of(Long.MAX_VALUE.toFloat())
+
+ assertThat(settings.getLongOrNull(floatKey)).isNull()
+ assertThat(settings.getIntOrNull(floatKey)).isNull()
+ }
+
+ companion object Data {
+ const val intKey = "int"
+ const val intVal = Int.MAX_VALUE
+
+ const val stringKey = "string"
+ const val stringVal = "String"
+
+ const val floatKey = "float"
+ const val floatVal = Float.MAX_VALUE
+
+ const val longKey = "long"
+ const val longVal = Long.MAX_VALUE
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt
new file mode 100644
index 00000000..fdc32207
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.pm.UserInfo
+import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID
+import android.os.UserHandle
+import android.os.UserManager
+import com.android.intentresolver.platform.FakeUserManager.ProfileType
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FakeUserManagerTest {
+ private val userManager = FakeUserManager()
+ private val state = userManager.state
+
+ @Test
+ fun initialState() {
+ val personal = userManager.getEnabledProfiles(state.primaryUserHandle.identifier).single()
+
+ assertThat(personal.id).isEqualTo(state.primaryUserHandle.identifier)
+ assertThat(personal.userType).isEqualTo(UserManager.USER_TYPE_FULL_SYSTEM)
+ assertThat(personal.flags and UserInfo.FLAG_FULL).isEqualTo(UserInfo.FLAG_FULL)
+ }
+
+ @Test
+ fun getProfileParent() {
+ val workHandle = state.createProfile(ProfileType.WORK)
+
+ assertThat(userManager.getProfileParent(state.primaryUserHandle)).isNull()
+ assertThat(userManager.getProfileParent(workHandle)).isEqualTo(state.primaryUserHandle)
+ assertThat(userManager.getProfileParent(UserHandle.of(-1))).isNull()
+ }
+
+ @Test
+ fun getUserInfo() {
+ val personalUser =
+ requireNotNull(userManager.getUserInfo(state.primaryUserHandle.identifier)) {
+ "Expected getUserInfo to return non-null"
+ }
+ assertTrue(userInfoAreEqual.apply(personalUser, state.getPrimaryUser()))
+
+ val workHandle = state.createProfile(ProfileType.WORK)
+
+ val workUser =
+ requireNotNull(userManager.getUserInfo(workHandle.identifier)) {
+ "Expected getUserInfo to return non-null"
+ }
+ assertTrue(
+ userInfoAreEqual.apply(workUser, userManager.getUserInfo(workHandle.identifier)!!)
+ )
+ }
+
+ @Test
+ fun getEnabledProfiles_usingParentId() {
+ val personal = state.primaryUserHandle
+ val work = state.createProfile(ProfileType.WORK)
+ val private = state.createProfile(ProfileType.PRIVATE)
+
+ val enabledProfiles = userManager.getEnabledProfiles(personal.identifier)
+
+ assertWithMessage("enabledProfiles: List<UserInfo>")
+ .that(enabledProfiles)
+ .comparingElementsUsing(userInfoEquality)
+ .displayingDiffsPairedBy { it.id }
+ .containsExactly(state.getPrimaryUser(), state.getUser(work), state.getUser(private))
+ }
+
+ @Test
+ fun getEnabledProfiles_usingProfileId() {
+ val clone = state.createProfile(ProfileType.CLONE)
+
+ val enabledProfiles = userManager.getEnabledProfiles(clone.identifier)
+
+ assertWithMessage("getEnabledProfiles(clone.identifier)")
+ .that(enabledProfiles)
+ .comparingElementsUsing(userInfoEquality)
+ .displayingDiffsPairedBy { it.id }
+ .containsExactly(state.getPrimaryUser(), state.getUser(clone))
+ }
+
+ @Test
+ fun getUserOrNull() {
+ val personal = state.getPrimaryUser()
+
+ assertThat(state.getUserOrNull(personal.userHandle)).isEqualTo(personal)
+ assertThat(state.getUserOrNull(UserHandle.of(personal.id - 1))).isNull()
+ }
+
+ @Test
+ fun createProfile() {
+ // Order dependent: profile creation modifies the primary user
+ val workHandle = state.createProfile(ProfileType.WORK)
+
+ val primaryUser = state.getPrimaryUser()
+ val workUser = state.getUser(workHandle)
+
+ assertThat(primaryUser.profileGroupId).isNotEqualTo(NO_PROFILE_GROUP_ID)
+ assertThat(workUser.profileGroupId).isEqualTo(primaryUser.profileGroupId)
+ }
+
+ @Test
+ fun removeProfile() {
+ val personal = state.getPrimaryUser()
+ val work = state.createProfile(ProfileType.WORK)
+ val private = state.createProfile(ProfileType.PRIVATE)
+
+ state.removeProfile(private)
+ assertThat(state.userHandles).containsExactly(personal.userHandle, work)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun removeProfile_primaryNotAllowed() {
+ state.removeProfile(state.primaryUserHandle)
+ }
+}
+
+private val userInfoAreEqual =
+ Correspondence.BinaryPredicate<UserInfo, UserInfo> { actual, expected ->
+ actual.id == expected.id &&
+ actual.profileGroupId == expected.profileGroupId &&
+ actual.userType == expected.userType &&
+ actual.flags == expected.flags
+ }
+
+val userInfoEquality: Correspondence<UserInfo, UserInfo> =
+ Correspondence.from(userInfoAreEqual, "==")
diff --git a/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt
new file mode 100644
index 00000000..a4bcad38
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.platform
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.res.Configuration
+import android.provider.Settings
+import android.testing.TestableResources
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.R
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+
+class NearbyShareModuleTest {
+
+ lateinit var context: Context
+
+ /** Create Resources with overridden values. */
+ private fun Context.fakeResources(
+ config: Configuration? = null,
+ block: TestableResources.() -> Unit,
+ ) =
+ TestableResources(resources)
+ .apply { config?.let { overrideConfiguration(it) } }
+ .apply(block)
+ .resources
+
+ @Before
+ fun setup() {
+ val instr = InstrumentationRegistry.getInstrumentation()
+ context = instr.context
+ }
+
+ @Test
+ fun valueIsAbsent_whenUnset() {
+ val secureSettings: SecureSettings = fakeSettings {}
+ val resources =
+ context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") }
+
+ val componentName = NearbyShareModule.nearbyShareComponent(resources, secureSettings)
+ assertThat(componentName).isEmpty()
+ }
+
+ @Test
+ fun defaultValue_readFromResources() {
+ val secureSettings: SecureSettings = fakeSettings {}
+ val resources =
+ context.fakeResources {
+ addOverride(
+ R.string.config_defaultNearbySharingComponent,
+ "com.example/.ComponentName",
+ )
+ }
+
+ val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings)
+
+ assertThat(nearbyShareComponent)
+ .hasValue(ComponentName.unflattenFromString("com.example/.ComponentName"))
+ }
+
+ @Test
+ fun secureSettings_overridesDefault() {
+ val secureSettings: SecureSettings = fakeSettings {
+ putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent")
+ }
+ val resources =
+ context.fakeResources {
+ addOverride(
+ R.string.config_defaultNearbySharingComponent,
+ "com.example/.AComponent",
+ )
+ }
+
+ val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings)
+
+ assertThat(nearbyShareComponent)
+ .hasValue(ComponentName.unflattenFromString("com.example/.BComponent"))
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt
new file mode 100644
index 00000000..8eafa0b5
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt
@@ -0,0 +1,342 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.profiles
+
+import android.os.UserHandle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ListView
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.R
+import com.android.intentresolver.ResolverListAdapter
+import com.android.intentresolver.emptystate.EmptyStateProvider
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL
+import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK
+import com.google.common.collect.ImmutableList
+import com.google.common.truth.Truth.assertThat
+import java.util.Optional
+import java.util.function.Supplier
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+class MultiProfilePagerAdapterTest {
+ private val PERSONAL_USER_HANDLE = UserHandle.of(10)
+ private val WORK_USER_HANDLE = UserHandle.of(20)
+
+ private val context = InstrumentationRegistry.getInstrumentation().getContext()
+ private val inflater = Supplier {
+ LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false)
+ as ViewGroup
+ }
+
+ @Test
+ fun testSinglePageProfileAdapter() {
+ val personalListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ )
+ ),
+ object : EmptyStateProvider {},
+ { false },
+ PROFILE_PERSONAL,
+ null,
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ assertThat(pagerAdapter.count).isEqualTo(1)
+ assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL)
+ assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE)
+ assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.workListAdapter).isNull()
+ assertThat(pagerAdapter.itemCount).isEqualTo(1)
+ // TODO: consider covering some of the package-private methods (and making them public?).
+ // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter.
+ }
+
+ @Test
+ fun testTwoProfilePagerAdapter() {
+ val personalListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE }
+ val workListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn WORK_USER_HANDLE }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ ),
+ TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter)
+ ),
+ object : EmptyStateProvider {},
+ { false },
+ PROFILE_PERSONAL,
+ WORK_USER_HANDLE, // TODO: why does this test pass even if this is null?
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ assertThat(pagerAdapter.count).isEqualTo(2)
+ assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL)
+ assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE)
+ assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter)
+ assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter)
+ assertThat(pagerAdapter.itemCount).isEqualTo(2)
+ // TODO: consider covering some of the package-private methods (and making them public?).
+ // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter;
+ // especially matching profiles to ListViews?
+ // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected
+ // page changes. Currently there's no API to change the selected page directly; that's
+ // only possible through manipulation of the bound ViewPager.
+ }
+
+ @Test
+ fun testTwoProfilePagerAdapter_workIsDefault() {
+ val personalListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE }
+ val workListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn WORK_USER_HANDLE }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ ),
+ TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter)
+ ),
+ object : EmptyStateProvider {},
+ { false },
+ PROFILE_WORK, // <-- This test specifically requests we start on work profile.
+ WORK_USER_HANDLE, // TODO: why does this test pass even if this is null?
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ assertThat(pagerAdapter.count).isEqualTo(2)
+ assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK)
+ assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE)
+ assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter)
+ assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter)
+ assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter)
+ assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter)
+ assertThat(pagerAdapter.itemCount).isEqualTo(2)
+ // TODO: consider covering some of the package-private methods (and making them public?).
+ // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected
+ // page changes. Currently there's no API to change the selected page directly; that's
+ // only possible through manipulation of the bound ViewPager.
+ }
+
+ @Test
+ fun testBottomPaddingDelegate_default() {
+ val personalListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ )
+ ),
+ object : EmptyStateProvider {},
+ { false },
+ PROFILE_PERSONAL,
+ null,
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ val container =
+ pagerAdapter.activeEmptyStateView.requireViewById<View>(
+ com.android.internal.R.id.resolver_empty_state_container
+ )
+ container.setPadding(1, 2, 3, 4)
+ pagerAdapter.setupContainerPadding()
+ assertThat(container.paddingLeft).isEqualTo(1)
+ assertThat(container.paddingTop).isEqualTo(2)
+ assertThat(container.paddingRight).isEqualTo(3)
+ assertThat(container.paddingBottom).isEqualTo(4)
+ }
+
+ @Test
+ fun testBottomPaddingDelegate_override() {
+ val personalListAdapter =
+ mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ )
+ ),
+ object : EmptyStateProvider {},
+ { false },
+ PROFILE_PERSONAL,
+ null,
+ null,
+ inflater,
+ { Optional.of(42) }
+ )
+ val container =
+ pagerAdapter.activeEmptyStateView.requireViewById<View>(
+ com.android.internal.R.id.resolver_empty_state_container
+ )
+ container.setPadding(1, 2, 3, 4)
+ pagerAdapter.setupContainerPadding()
+ assertThat(container.paddingLeft).isEqualTo(1)
+ assertThat(container.paddingTop).isEqualTo(2)
+ assertThat(container.paddingRight).isEqualTo(3)
+ assertThat(container.paddingBottom).isEqualTo(42)
+ }
+
+ @Test
+ fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() {
+ // TODO: this is "presumed" because the conditions to determine whether we "should" show an
+ // empty state aren't enforced to align with the conditions when we actually *would* -- I
+ // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider?
+ val personalListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn PERSONAL_USER_HANDLE
+ on { unfilteredCount } doReturn 1
+ }
+ val workListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn WORK_USER_HANDLE
+ on { unfilteredCount } doReturn 1
+ }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ ),
+ TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter)
+ ),
+ object : EmptyStateProvider {},
+ { true }, // <-- Work mode is quiet.
+ PROFILE_WORK,
+ WORK_USER_HANDLE,
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue()
+ assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse()
+ }
+
+ @Test
+ fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() {
+ // TODO: this is "presumed" because the conditions to determine whether we "should" show an
+ // empty state aren't enforced to align with the conditions when we actually *would* -- I
+ // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider?
+ val personalListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn PERSONAL_USER_HANDLE
+ on { unfilteredCount } doReturn 1
+ }
+ val workListAdapter =
+ mock<ResolverListAdapter> {
+ on { userHandle } doReturn WORK_USER_HANDLE
+ on { unfilteredCount } doReturn 1
+ }
+ val pagerAdapter =
+ MultiProfilePagerAdapter(
+ { listAdapter: ResolverListAdapter -> listAdapter },
+ { listView: ListView, bindAdapter: ResolverListAdapter ->
+ listView.setAdapter(bindAdapter)
+ },
+ ImmutableList.of(
+ TabConfig(
+ PROFILE_PERSONAL,
+ "personal",
+ "personal_a11y",
+ "TAG_PERSONAL",
+ personalListAdapter
+ ),
+ TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter)
+ ),
+ object : EmptyStateProvider {},
+ { false }, // <-- Work mode is not quiet.
+ PROFILE_WORK,
+ WORK_USER_HANDLE,
+ null,
+ inflater,
+ { Optional.empty() }
+ )
+ assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse()
+ assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt
new file mode 100644
index 00000000..c81e88ab
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallbackTest.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.intentresolver.shortcuts
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ScopedAppTargetListCallbackTest {
+
+ @Test
+ fun test_consumerInvocations_onlyInvokedWhileScopeIsActive() {
+ val scope = TestScope(UnconfinedTestDispatcher())
+ var counter = 0
+ val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toConsumer()
+
+ testSubject.accept(ArrayList())
+
+ assertThat(counter).isEqualTo(1)
+
+ scope.cancel()
+ testSubject.accept(ArrayList())
+
+ assertThat(counter).isEqualTo(1)
+ }
+
+ @Test
+ fun test_appPredictorCallbackInvocations_onlyInvokedWhileScopeIsActive() {
+ val scope = TestScope(UnconfinedTestDispatcher())
+ var counter = 0
+ val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toAppPredictorCallback()
+
+ testSubject.onTargetsAvailable(ArrayList())
+
+ assertThat(counter).isEqualTo(1)
+
+ scope.cancel()
+ testSubject.onTargetsAvailable(ArrayList())
+
+ assertThat(counter).isEqualTo(1)
+ }
+
+ @Test
+ fun test_createdWithClosedScope_noCallbackInvocations() {
+ val scope = TestScope(UnconfinedTestDispatcher()).apply { cancel() }
+ var counter = 0
+ val testSubject = ScopedAppTargetListCallback(scope) { counter++ }.toConsumer()
+
+ testSubject.accept(ArrayList())
+
+ assertThat(counter).isEqualTo(0)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
new file mode 100644
index 00000000..eb5297b4
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
@@ -0,0 +1,698 @@
+/*
+ * Copyright (C) 2022 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.intentresolver.shortcuts
+
+import android.app.prediction.AppPredictor
+import android.content.ComponentName
+import android.content.Context
+import android.content.IntentFilter
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.ApplicationInfoFlags
+import android.content.pm.ShortcutManager
+import android.os.UserHandle
+import android.os.UserManager
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import androidx.test.filters.SmallTest
+import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUTS_FLASHING_FIXED
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.createAppTarget
+import com.android.intentresolver.createShareShortcutInfo
+import com.android.intentresolver.createShortcutInfo
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.function.Consumer
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+class ShortcutLoaderTest {
+ @get:Rule val flagRule = SetFlagsRule()
+
+ private val appInfo =
+ ApplicationInfo().apply {
+ enabled = true
+ flags = 0
+ }
+ private val pm =
+ mock<PackageManager> {
+ on { getApplicationInfo(any(), any<ApplicationInfoFlags>()) } doReturn appInfo
+ }
+ private val userManager =
+ mock<UserManager> {
+ on { isUserRunning(any<UserHandle>()) } doReturn true
+ on { isUserUnlocked(any<UserHandle>()) } doReturn true
+ on { isQuietModeEnabled(any<UserHandle>()) } doReturn false
+ }
+ private val context =
+ mock<Context> {
+ on { packageManager } doReturn pm
+ on { createContextAsUser(any(), any()) } doReturn mock
+ on { getSystemService(Context.USER_SERVICE) } doReturn userManager
+ }
+ private val scheduler = TestCoroutineScheduler()
+ private val dispatcher = UnconfinedTestDispatcher(scheduler)
+ private val scope = TestScope(dispatcher)
+ private val intentFilter = mock<IntentFilter>()
+ private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ private val callback = mock<Consumer<ShortcutLoader.Result>>()
+ private val componentName = ComponentName("pkg", "Class")
+ private val appTarget =
+ mock<DisplayResolveInfo> { on { resolvedComponentName } doReturn componentName }
+ private val appTargets = arrayOf(appTarget)
+ private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1)
+
+ @Test
+ fun test_loadShortcutsWithAppPredictor_resultIntegrity() =
+ scope.runTest {
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ val matchingAppTarget = createAppTarget(matchingShortcutInfo)
+ val shortcuts =
+ listOf(
+ matchingAppTarget,
+ // an AppTarget that does not belong to any resolved application; should be
+ // ignored
+ createAppTarget(
+ createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
+ ),
+ )
+ val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
+ verify(appPredictor, atLeastOnce())
+ .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+ appPredictorCallbackCaptor.firstValue.onTargetsAvailable(shortcuts)
+
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.firstValue
+ assertTrue("An app predictor result is expected", result.isFromAppPredictor)
+ assertArrayEquals(
+ "Wrong input app targets in the result",
+ appTargets,
+ result.appTargets,
+ )
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertEquals(
+ "Wrong AppTarget in the cache",
+ matchingAppTarget,
+ result.directShareAppTargetCache[shortcut],
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut],
+ )
+ }
+ }
+
+ @Test
+ fun test_loadShortcutsWithShortcutManager_resultIntegrity() =
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn shortcutManagerResult
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ null,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.firstValue
+ assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
+ assertArrayEquals(
+ "Wrong input app targets in the result",
+ appTargets,
+ result.appTargets,
+ )
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertTrue(
+ "AppTargets are not expected the cache of a ShortcutManager result",
+ result.directShareAppTargetCache.isEmpty(),
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut],
+ )
+ }
+ }
+
+ @Test
+ fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() =
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn (shortcutManagerResult)
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
+ verify(appPredictor, times(1))
+ .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+ appPredictorCallbackCaptor.firstValue.onTargetsAvailable(emptyList())
+
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.firstValue
+ assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
+ assertArrayEquals(
+ "Wrong input app targets in the result",
+ appTargets,
+ result.appTargets,
+ )
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertTrue(
+ "AppTargets are not expected the cache of a ShortcutManager result",
+ result.directShareAppTargetCache.isEmpty(),
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut],
+ )
+ }
+ }
+
+ @Test
+ fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() =
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn shortcutManagerResult
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ whenever(appPredictor.requestPredictionUpdate())
+ .thenThrow(IllegalStateException("Test exception"))
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.firstValue
+ assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
+ assertArrayEquals(
+ "Wrong input app targets in the result",
+ appTargets,
+ result.appTargets,
+ )
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertTrue(
+ "AppTargets are not expected the cache of a ShortcutManager result",
+ result.directShareAppTargetCache.isEmpty(),
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut],
+ )
+ }
+ }
+
+ @Test
+ @DisableFlags(FLAG_FIX_SHORTCUTS_FLASHING_FIXED)
+ fun test_appPredictorNotResponding_noCallbackFromShortcutLoader() {
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn shortcutManagerResult
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+
+ scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS * 2)
+ verify(callback, never()).accept(any())
+ }
+ }
+
+ @Test
+ @EnableFlags(FLAG_FIX_SHORTCUTS_FLASHING_FIXED)
+ fun test_appPredictorNotResponding_timeoutAndFallbackToShortcutManager() {
+ scope.runTest {
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ val matchingAppTarget = createAppTarget(matchingShortcutInfo)
+ val shortcuts =
+ listOf(
+ matchingAppTarget,
+ // an AppTarget that does not belong to any resolved application; should be
+ // ignored
+ createAppTarget(
+ createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
+ ),
+ )
+ val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
+ verify(appPredictor, atLeastOnce())
+ .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+ appPredictorCallbackCaptor.firstValue.onTargetsAvailable(shortcuts)
+
+ scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS * 2)
+ verify(callback, times(1)).accept(any())
+ }
+ }
+
+ @Test
+ @EnableFlags(FLAG_FIX_SHORTCUTS_FLASHING_FIXED)
+ fun test_appPredictorResponding_appPredictorTimeoutJobIsCancelled() {
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn shortcutManagerResult
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+
+ scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS / 2)
+ verify(callback, never()).accept(any())
+
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS)
+ verify(callback, times(1)).accept(resultCaptor.capture())
+ val result = resultCaptor.firstValue
+ assertWithMessage("An ShortcutManager result is expected")
+ .that(result.isFromAppPredictor)
+ .isFalse()
+ assertWithMessage("Wrong input app targets in the result")
+ .that(appTargets)
+ .asList()
+ .containsExactlyElementsIn(result.appTargets)
+ .inOrder()
+ assertWithMessage("Wrong shortcut count").that(result.shortcutsByApp).hasLength(1)
+ assertWithMessage("Wrong app target")
+ .that(appTarget)
+ .isEqualTo(result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertWithMessage(
+ "AppTargets are not expected the cache of a ShortcutManager result"
+ )
+ .that(result.directShareAppTargetCache)
+ .isEmpty()
+ assertWithMessage("Wrong ShortcutInfo in the cache")
+ .that(matchingShortcutInfo)
+ .isEqualTo(result.directShareShortcutInfoCache[shortcut])
+ }
+ }
+ }
+
+ @Test
+ fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() =
+ scope.runTest {
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ verify(callback, never()).accept(any())
+ }
+
+ @Test
+ fun test_ShortcutLoader_noResultsWithoutAppTargets() =
+ scope.runTest {
+ val shortcutManagerResult =
+ listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1),
+ )
+ val shortcutManager =
+ mock<ShortcutManager> {
+ on { getShareTargets(intentFilter) } doReturn shortcutManagerResult
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ null,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ verify(shortcutManager, times(1)).getShareTargets(any())
+ verify(callback, never()).accept(any())
+
+ testSubject.reset()
+
+ verify(shortcutManager, times(2)).getShareTargets(any())
+ verify(callback, never()).accept(any())
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(shortcutManager, times(2)).getShareTargets(any())
+ verify(callback, times(1)).accept(any())
+ }
+
+ @Test
+ fun test_OnScopeCancellation_unsubscribeFromAppPredictor() {
+ scope.runTest {
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ verify(appPredictor, never()).unregisterPredictionUpdates(any())
+ }
+
+ verify(appPredictor, times(1)).unregisterPredictionUpdates(any())
+ }
+
+ @Test
+ fun test_nullIntentFilterNoAppAppPredictorResults_returnEmptyResult() =
+ scope.runTest {
+ val shortcutManager = mock<ShortcutManager>()
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ isPersonalProfile = true,
+ targetIntentFilter = null,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
+ verify(appPredictor, times(1))
+ .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+ appPredictorCallbackCaptor.firstValue.onTargetsAvailable(emptyList())
+
+ verify(shortcutManager, never()).getShareTargets(any())
+ val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.firstValue
+ assertWithMessage("A ShortcutManager result is expected")
+ .that(result.isFromAppPredictor)
+ .isFalse()
+ assertArrayEquals(
+ "Wrong input app targets in the result",
+ appTargets,
+ result.appTargets,
+ )
+ assertWithMessage("An empty result is expected").that(result.shortcutsByApp).isEmpty()
+ }
+
+ @Test
+ fun test_workProfileNotRunning_doNotCallServices() {
+ testDisabledWorkProfileDoNotCallSystem(isUserRunning = false)
+ }
+
+ @Test
+ fun test_workProfileLocked_doNotCallServices() {
+ testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false)
+ }
+
+ @Test
+ fun test_workProfileQuiteModeEnabled_doNotCallServices() {
+ testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true)
+ }
+
+ @Test
+ fun test_mainProfileNotRunning_callServicesAnyway() {
+ testAlwaysCallSystemForMainProfile(isUserRunning = false)
+ }
+
+ @Test
+ fun test_mainProfileLocked_callServicesAnyway() {
+ testAlwaysCallSystemForMainProfile(isUserUnlocked = false)
+ }
+
+ @Test
+ fun test_mainProfileQuiteModeEnabled_callServicesAnyway() {
+ testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true)
+ }
+
+ @Test
+ fun test_ShortcutLoaderDestroyed_appPredictorCallbackUnregisteredAndWatchdogCancelled() {
+ scope.runTest {
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(appTargets)
+ testSubject.destroy()
+
+ verify(appPredictor, times(1)).registerPredictionUpdates(any(), any())
+ verify(appPredictor, times(1)).unregisterPredictionUpdates(any())
+ }
+ }
+
+ private fun testDisabledWorkProfileDoNotCallSystem(
+ isUserRunning: Boolean = true,
+ isUserUnlocked: Boolean = true,
+ isQuietModeEnabled: Boolean = false,
+ ) =
+ scope.runTest {
+ val userHandle = UserHandle.of(10)
+ with(userManager) {
+ whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
+ whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
+ whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
+ }
+ whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
+ val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ val callback = mock<Consumer<ShortcutLoader.Result>>()
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ userHandle,
+ false,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock()))
+
+ verify(appPredictor, never()).requestPredictionUpdate()
+ }
+
+ private fun testAlwaysCallSystemForMainProfile(
+ isUserRunning: Boolean = true,
+ isUserUnlocked: Boolean = true,
+ isQuietModeEnabled: Boolean = false,
+ ) =
+ scope.runTest {
+ val userHandle = UserHandle.of(10)
+ with(userManager) {
+ whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
+ whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
+ whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
+ }
+ whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
+ val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ val callback = mock<Consumer<ShortcutLoader.Result>>()
+ val testSubject =
+ ShortcutLoader(
+ context,
+ backgroundScope,
+ appPredictor,
+ userHandle,
+ true,
+ intentFilter,
+ dispatcher,
+ callback,
+ )
+
+ testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock()))
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
index e0de005d..ce6ef477 100644
--- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
+++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
@@ -32,9 +32,9 @@ private const val PACKAGE = "org.package"
class ShortcutToChooserTargetConverterTest {
private val testSubject = ShortcutToChooserTargetConverter()
- private val ranks = arrayOf(3 ,7, 1 ,3)
- private val shortcuts = ranks
- .foldIndexed(ArrayList<ShareShortcutInfo>(ranks.size)) { i, acc, rank ->
+ private val ranks = arrayOf(3, 7, 1, 3)
+ private val shortcuts =
+ ranks.foldIndexed(ArrayList<ShareShortcutInfo>(ranks.size)) { i, acc, rank ->
val id = i + 1
acc.add(
createShareShortcutInfo(
@@ -54,13 +54,14 @@ class ShortcutToChooserTargetConverterTest {
val appTargetCache = HashMap<ChooserTarget, AppTarget>()
val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
- var chooserTargets = testSubject.convertToChooserTarget(
- shortcuts,
- shortcuts,
- appTargets,
- appTargetCache,
- shortcutInfoCache,
- )
+ var chooserTargets =
+ testSubject.convertToChooserTarget(
+ shortcuts,
+ shortcuts,
+ appTargets,
+ appTargetCache,
+ shortcutInfoCache,
+ )
assertCorrectShortcutToChooserTargetConversion(
shortcuts,
@@ -77,13 +78,14 @@ class ShortcutToChooserTargetConverterTest {
appTargetCache.clear()
shortcutInfoCache.clear()
- chooserTargets = testSubject.convertToChooserTarget(
- subset,
- shortcuts,
- appTargets,
- appTargetCache,
- shortcutInfoCache,
- )
+ chooserTargets =
+ testSubject.convertToChooserTarget(
+ subset,
+ shortcuts,
+ appTargets,
+ appTargetCache,
+ shortcutInfoCache,
+ )
assertCorrectShortcutToChooserTargetConversion(
shortcuts,
@@ -102,17 +104,20 @@ class ShortcutToChooserTargetConverterTest {
val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f)
val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
- var chooserTargets = testSubject.convertToChooserTarget(
- shortcuts,
- shortcuts,
- null,
- null,
- shortcutInfoCache,
- )
+ var chooserTargets =
+ testSubject.convertToChooserTarget(
+ shortcuts,
+ shortcuts,
+ null,
+ null,
+ shortcutInfoCache,
+ )
assertCorrectShortcutToChooserTargetConversion(
- shortcuts, chooserTargets,
- expectedOrderAllShortcuts, expectedScoreAllShortcuts
+ shortcuts,
+ chooserTargets,
+ expectedOrderAllShortcuts,
+ expectedScoreAllShortcuts
)
assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
@@ -124,17 +129,20 @@ class ShortcutToChooserTargetConverterTest {
val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f)
shortcutInfoCache.clear()
- chooserTargets = testSubject.convertToChooserTarget(
- subset,
- shortcuts,
- null,
- null,
- shortcutInfoCache,
- )
+ chooserTargets =
+ testSubject.convertToChooserTarget(
+ subset,
+ shortcuts,
+ null,
+ null,
+ shortcutInfoCache,
+ )
assertCorrectShortcutToChooserTargetConversion(
- shortcuts, chooserTargets,
- expectedOrderSubset, expectedScoreSubset
+ shortcuts,
+ chooserTargets,
+ expectedOrderSubset,
+ expectedScoreSubset
)
assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
}
@@ -158,7 +166,8 @@ class ShortcutToChooserTargetConverterTest {
}
private fun assertAppTargetCache(
- chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget>
+ chooserTargets: List<ChooserTarget>,
+ cache: Map<ChooserTarget, AppTarget>
) {
for (ct in chooserTargets) {
val target = cache[ct]
@@ -167,7 +176,8 @@ class ShortcutToChooserTargetConverterTest {
}
private fun assertShortcutInfoCache(
- chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo>
+ chooserTargets: List<ChooserTarget>,
+ cache: Map<ChooserTarget, ShortcutInfo>
) {
for (ct in chooserTargets) {
val si = cache[ct]
diff --git a/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt
new file mode 100644
index 00000000..d8b1b175
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui
+
+import android.app.PendingIntent
+import android.compat.testing.PlatformCompatChangeRule
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Process
+import android.service.chooser.ChooserResult
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.ui.model.ShareAction
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges
+import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ShareResultSenderImplTest {
+
+ private val context = InstrumentationRegistry.getInstrumentation().context
+
+ @get:Rule val compatChangeRule: TestRule = PlatformCompatChangeRule()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onComponentSelected_chooserResultEnabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, false)
+ runCurrent()
+
+ val intentReceived = deferred.await()
+ val chooserResult =
+ intentReceived.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult::class.java
+ )
+ assertThat(chooserResult).isNotNull()
+ assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT)
+ assertThat(chooserResult?.selectedComponent).isEqualTo(ComponentName("example.com", "Foo"))
+ assertThat(chooserResult?.isShortcut).isTrue()
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onComponentSelected_crossProfile_chooserResultEnabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ // Invoke as in the previous test, but this time say that the selection was cross-profile.
+ resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, true)
+ runCurrent()
+
+ val intentReceived = deferred.await()
+ val chooserResult =
+ intentReceived.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult::class.java
+ )
+ assertThat(chooserResult).isNotNull()
+ assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_UNKNOWN)
+ assertThat(chooserResult?.selectedComponent).isNull()
+ assertThat(chooserResult?.isShortcut).isTrue()
+ assertThat(intentReceived.hasExtra(Intent.EXTRA_CHOSEN_COMPONENT)).isFalse()
+ }
+
+ @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onComponentSelected_chooserResultDisabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, false)
+ runCurrent()
+
+ val intentReceived = deferred.await()
+ val componentName =
+ intentReceived.getParcelableExtra(
+ Intent.EXTRA_CHOSEN_COMPONENT,
+ ComponentName::class.java
+ )
+
+ assertWithMessage("EXTRA_CHOSEN_COMPONENT from received intent")
+ .that(componentName)
+ .isEqualTo(ComponentName("example.com", "Foo"))
+
+ assertWithMessage("received intent has EXTRA_CHOOSER_RESULT")
+ .that(intentReceived.hasExtra(Intent.EXTRA_CHOOSER_RESULT))
+ .isFalse()
+ }
+
+ @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onComponentSelected_crossProfile_chooserResultDisabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ // Invoke as in the previous test, but this time say that the selection was cross-profile.
+ resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, true)
+ runCurrent()
+
+ // In the pre-ChooserResult API, no callback intent is sent for cross-profile selections.
+ assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse()
+ }
+
+ @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onActionSelected_chooserResultEnabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ resultSender.onActionSelected(ShareAction.SYSTEM_COPY)
+ runCurrent()
+
+ val intentReceived = deferred.await()
+ val chosenComponent =
+ intentReceived.getParcelableExtra(
+ Intent.EXTRA_CHOSEN_COMPONENT,
+ ChooserResult::class.java
+ )
+ assertThat(chosenComponent).isNull()
+
+ val chooserResult =
+ intentReceived.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult::class.java
+ )
+ assertThat(chooserResult).isNotNull()
+ assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_COPY)
+ assertThat(chooserResult?.selectedComponent).isNull()
+ assertThat(chooserResult?.isShortcut).isFalse()
+ }
+
+ @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT)
+ @Test
+ fun onActionSelected_chooserResultDisabled() = runTest {
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE)
+ val deferred = CompletableDeferred<Intent>()
+ val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) }
+
+ val resultSender =
+ ShareResultSenderImpl(
+ scope = this,
+ backgroundDispatcher = UnconfinedTestDispatcher(testScheduler),
+ callerUid = Process.myUid(),
+ resultSender = pi.intentSender,
+ intentDispatcher = intentDispatcher
+ )
+
+ resultSender.onActionSelected(ShareAction.SYSTEM_COPY)
+ runCurrent()
+
+ // No result should have been sent, this should never complete
+ assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt
new file mode 100644
index 00000000..b48a6422
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.model
+
+import android.content.Intent
+import android.content.Intent.ACTION_CHOOSER
+import android.content.Intent.EXTRA_TEXT
+import android.net.Uri
+import com.android.intentresolver.ext.toParcelAndBack
+import com.android.intentresolver.shared.model.ActivityModel
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+
+class ActivityModelTest {
+
+ @Test
+ fun testDefaultValues() {
+ val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null, false)
+
+ val output = input.toParcelAndBack()
+
+ assertEquals(input, output)
+ }
+
+ @Test
+ fun testCommonValues() {
+ val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") }
+ val input =
+ ActivityModel(
+ intent,
+ 1234,
+ "com.example",
+ Uri.parse("android-app://example.com"),
+ false,
+ )
+
+ val output = input.toParcelAndBack()
+
+ assertEquals(input, output)
+ }
+
+ @Test
+ fun testReferrerPackage_withAppReferrer_usesReferrer() {
+ val launch1 =
+ ActivityModel(
+ intent = Intent(),
+ launchedFromUid = 1000,
+ launchedFromPackage = "other.example.com",
+ referrer = Uri.parse("android-app://app.example.com"),
+ false,
+ )
+
+ assertThat(launch1.referrerPackage).isEqualTo("app.example.com")
+ }
+
+ @Test
+ fun testReferrerPackage_httpReferrer_isNull() {
+ val launch =
+ ActivityModel(
+ intent = Intent(),
+ launchedFromUid = 1000,
+ launchedFromPackage = "example.com",
+ referrer = Uri.parse("http://some.other.value"),
+ false,
+ )
+
+ assertThat(launch.referrerPackage).isNull()
+ }
+
+ @Test
+ fun testReferrerPackage_nullReferrer_isNull() {
+ val launch =
+ ActivityModel(
+ intent = Intent(),
+ launchedFromUid = 1000,
+ launchedFromPackage = "example.com",
+ referrer = null,
+ false,
+ )
+
+ assertThat(launch.referrerPackage).isNull()
+ }
+
+ private fun assertEquals(expected: ActivityModel, actual: ActivityModel) {
+ // Test fields separately: Intent does not override equals()
+ assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent)
+ .that(actual.intent.filterEquals(expected.intent))
+ .isTrue()
+
+ assertWithMessage("actual fromUid is equal to expected")
+ .that(actual.launchedFromUid)
+ .isEqualTo(expected.launchedFromUid)
+
+ assertWithMessage("actual fromPackage is equal to expected")
+ .that(actual.launchedFromPackage)
+ .isEqualTo(expected.launchedFromPackage)
+
+ assertWithMessage("actual referrer is equal to expected")
+ .that(actual.referrer)
+ .isEqualTo(expected.referrer)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt
new file mode 100644
index 00000000..7bc1e785
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.Intent
+import android.content.Intent.ACTION_CHOOSER
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.ACTION_VIEW
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI
+import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_REFERRER
+import android.content.Intent.EXTRA_TEXT
+import android.content.Intent.EXTRA_TITLE
+import android.net.Uri
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import androidx.core.net.toUri
+import androidx.core.os.bundleOf
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.data.model.ChooserRequest
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.validation.Importance
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.systemui.shared.Flags
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+
+private fun createActivityModel(
+ targetIntent: Intent?,
+ referrer: Uri? = null,
+ additionalIntents: List<Intent>? = null,
+ launchedFromPackage: String = "com.android.example",
+) =
+ ActivityModel(
+ Intent(ACTION_CHOOSER).apply {
+ targetIntent?.also { putExtra(EXTRA_INTENT, it) }
+ additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) }
+ },
+ launchedFromUid = 10000,
+ launchedFromPackage = launchedFromPackage,
+ referrer = referrer ?: "android-app://$launchedFromPackage".toUri(),
+ false,
+ )
+
+class ChooserRequestTest {
+ @get:Rule val flagsRule = SetFlagsRule()
+
+ @Test
+ fun missingIntent() {
+ val model = createActivityModel(targetIntent = null)
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<ChooserRequest>
+
+ assertThat(result.errors)
+ .containsExactly(NoValue(EXTRA_INTENT, Importance.CRITICAL, Intent::class))
+ }
+
+ @Test
+ fun referrerFillIn() {
+ val referrer = Uri.parse("android-app://example.com")
+ val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer)
+ model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer))
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ val fillIn = result.value.getReferrerFillInIntent()
+ assertThat(fillIn.hasExtra(EXTRA_REFERRER)).isTrue()
+ assertThat(fillIn.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer)
+ }
+
+ @Test
+ fun referrerPackage_isNullWithNonAppReferrer() {
+ val referrer = Uri.parse("http://example.com")
+ val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)))
+
+ val model = createActivityModel(targetIntent = intent, referrer = referrer)
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.referrerPackage).isNull()
+ }
+
+ @Test
+ fun referrerPackage_fromAppReferrer() {
+ val referrer = Uri.parse("android-app://example.com")
+ val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer)
+
+ model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer))
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.referrerPackage).isEqualTo(referrer.authority)
+ }
+
+ @Test
+ fun payloadIntents_includesTargetThenAdditional() {
+ val intent1 = Intent(ACTION_SEND)
+ val intent2 = Intent(ACTION_SEND_MULTIPLE)
+ val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2))
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.payloadIntents).containsExactly(intent1, intent2)
+ }
+
+ @Test
+ fun testRequest_withOnlyRequiredValues() {
+ val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)))
+ val model = createActivityModel(targetIntent = intent)
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.launchedFromPackage).isEqualTo(model.launchedFromPackage)
+ }
+
+ @Test
+ fun testRequest_actionSendWithAdditionalContentUri() {
+ val uri = Uri.parse("content://org.pkg/path")
+ val position = 10
+ val model =
+ createActivityModel(targetIntent = Intent(ACTION_SEND)).apply {
+ intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri)
+ intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position)
+ }
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.additionalContentUri).isEqualTo(uri)
+ assertThat(result.value.focusedItemPosition).isEqualTo(position)
+ }
+
+ @Test
+ fun testRequest_actionSendWithInvalidAdditionalContentUri() {
+ val model =
+ createActivityModel(targetIntent = Intent(ACTION_SEND)).apply {
+ intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__")
+ intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__")
+ }
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.additionalContentUri).isNull()
+ assertThat(result.value.focusedItemPosition).isEqualTo(0)
+ }
+
+ @Test
+ fun testRequest_actionSendWithoutAdditionalContentUri() {
+ val model = createActivityModel(targetIntent = Intent(ACTION_SEND))
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.additionalContentUri).isNull()
+ assertThat(result.value.focusedItemPosition).isEqualTo(0)
+ }
+
+ @Test
+ fun testRequest_actionViewWithAdditionalContentUri() {
+ val uri = Uri.parse("content://org.pkg/path")
+ val position = 10
+ val model =
+ createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply {
+ intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri)
+ intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position)
+ }
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.additionalContentUri).isNull()
+ assertThat(result.value.focusedItemPosition).isEqualTo(0)
+ assertThat(result.warnings).isEmpty()
+ }
+
+ @Test
+ fun testAlbumType() {
+ val model = createActivityModel(Intent(ACTION_SEND))
+ model.intent.putExtra(
+ Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT,
+ Intent.CHOOSER_CONTENT_TYPE_ALBUM,
+ )
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM)
+ assertThat(result.warnings).isEmpty()
+ }
+
+ @Test
+ fun metadataText_isPassedText() {
+ // Arrange
+ val metadataText: CharSequence = "Test metadata text"
+ val model =
+ createActivityModel(targetIntent = Intent()).apply {
+ intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText)
+ }
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.metadataText).isEqualTo(metadataText)
+ }
+
+ @Test
+ fun textSharedTextAndTitle() {
+ val text: CharSequence = "Shared text"
+ val title: CharSequence = "Title"
+ val targetIntent =
+ Intent().apply {
+ putExtra(EXTRA_TITLE, title)
+ putExtra(EXTRA_TEXT, text)
+ }
+ val model = createActivityModel(targetIntent)
+
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ (result as Valid<ChooserRequest>).value.let { request ->
+ assertThat(request.sharedText).isEqualTo(text)
+ assertThat(request.sharedTextTitle).isEqualTo(title)
+ }
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL)
+ fun testCallerAllowsTextToggle_flagOff() {
+ val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)))
+ val model =
+ createActivityModel(targetIntent = intent, launchedFromPackage = "com.android.systemui")
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.callerAllowsTextToggle).isFalse()
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL)
+ fun testCallerAllowsTextToggle_sysuiPackage() {
+ val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)))
+ val model =
+ createActivityModel(targetIntent = intent, launchedFromPackage = "com.android.systemui")
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.callerAllowsTextToggle).isTrue()
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL)
+ fun testCallerAllowsTextToggle_otherPackage() {
+ val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)))
+ val model =
+ createActivityModel(targetIntent = intent, launchedFromPackage = "com.hello.world")
+ val result = readChooserRequest(model)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ChooserRequest>
+
+ assertThat(result.value.callerAllowsTextToggle).isFalse()
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt
new file mode 100644
index 00000000..8fc162ca
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.EXTRA_STREAM
+import android.net.Uri
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class IntentExtTest {
+
+ @Test
+ fun noActionOrUris() {
+ val intent = Intent()
+
+ assertThat(intent.createIntentFilter()).isNull()
+ }
+
+ @Test
+ fun uriInData() {
+ val intent = Intent(ACTION_SEND)
+ intent.setDataAndType(
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ "image/png",
+ )
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND)
+ assertThat(filter.schemesIterator().next()).isEqualTo("scheme1")
+ assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ }
+
+ @Test
+ fun noAction() {
+ val intent = Intent()
+ intent.setDataAndType(
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ "image/png",
+ )
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.countActions()).isEqualTo(0)
+ assertThat(filter.schemesIterator().next()).isEqualTo("scheme1")
+ assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ }
+
+ @Test
+ fun singleUriInExtraStream() {
+ val intent = Intent(ACTION_SEND)
+ intent.type = "image/png"
+ intent.putExtra(
+ EXTRA_STREAM,
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ )
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND)
+ assertThat(filter.schemesIterator().next()).isEqualTo("scheme1")
+ assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ }
+
+ @Test
+ fun uriInDataAndStream() {
+ val intent = Intent(ACTION_SEND)
+ intent.setDataAndType(
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ "image/png",
+ )
+
+ intent.putExtra(
+ EXTRA_STREAM,
+ Uri.Builder().scheme("scheme2").encodedAuthority("auth2").path("path2").build(),
+ )
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND)
+ assertThat(filter.getDataScheme(0)).isEqualTo("scheme1")
+ assertThat(filter.getDataScheme(1)).isEqualTo("scheme2")
+ assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1")
+ assertThat(filter.getDataAuthority(1).host).isEqualTo("auth2")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ assertThat(filter.getDataPath(1).path).isEqualTo("/path2")
+ }
+
+ @Test
+ fun multipleUris() {
+ val intent = Intent(ACTION_SEND)
+ intent.type = "image/png"
+ val uris =
+ arrayListOf(
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ Uri.Builder().scheme("scheme2").encodedAuthority("auth2").path("path2").build(),
+ )
+ intent.putExtra(EXTRA_STREAM, uris)
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND)
+ assertThat(filter.getDataScheme(0)).isEqualTo("scheme1")
+ assertThat(filter.getDataScheme(1)).isEqualTo("scheme2")
+ assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1")
+ assertThat(filter.getDataAuthority(1).host).isEqualTo("auth2")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ assertThat(filter.getDataPath(1).path).isEqualTo("/path2")
+ }
+
+ @Test
+ fun multipleUrisWithNullValues() {
+ val intent = Intent(ACTION_SEND)
+ intent.type = "image/png"
+ val uris =
+ arrayListOf(
+ null,
+ Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(),
+ null,
+ )
+ intent.putExtra(EXTRA_STREAM, uris)
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png")
+ assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND)
+ assertThat(filter.getDataScheme(0)).isEqualTo("scheme1")
+ assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1")
+ assertThat(filter.getDataPath(0).path).isEqualTo("/path1")
+ }
+
+ @Test
+ fun badMimeType() {
+ val intent = Intent(ACTION_SEND)
+ intent.type = "badType"
+ intent.putExtra(
+ EXTRA_STREAM,
+ Uri.Builder().scheme("scheme1").encodedAuthority("authority1").path("path1").build(),
+ )
+
+ val filter = intent.createIntentFilter()
+
+ assertThat(filter).isNotNull()
+ assertThat(filter!!.countDataTypes()).isEqualTo(0)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt
new file mode 100644
index 00000000..be6560c2
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.ui.viewmodel
+
+import android.content.Intent
+import android.content.Intent.ACTION_VIEW
+import android.net.Uri
+import android.os.UserHandle
+import androidx.core.net.toUri
+import androidx.core.os.bundleOf
+import com.android.intentresolver.ResolverActivity.PROFILE_WORK
+import com.android.intentresolver.shared.model.ActivityModel
+import com.android.intentresolver.shared.model.Profile.Type.WORK
+import com.android.intentresolver.ui.model.ResolverRequest
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.UncaughtException
+import com.android.intentresolver.validation.Valid
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+
+private val targetUri = Uri.parse("content://example.com/123")
+
+private fun createActivityModel(targetIntent: Intent, referrer: Uri? = null) =
+ ActivityModel(
+ intent = targetIntent,
+ launchedFromUid = 10000,
+ launchedFromPackage = "com.android.example",
+ referrer = referrer ?: "android-app://com.android.example".toUri(),
+ false,
+ )
+
+class ResolverRequestTest {
+ @Test
+ fun testDefaults() {
+ val intent = Intent(ACTION_VIEW).apply { data = targetUri }
+ val activity = createActivityModel(intent)
+
+ val result = readResolverRequest(activity)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ResolverRequest>
+
+ assertThat(result.warnings).isEmpty()
+
+ assertThat(result.value.intent.filterEquals(activity.intent)).isTrue()
+ assertThat(result.value.callingUser).isNull()
+ assertThat(result.value.selectedProfile).isNull()
+ }
+
+ @Test
+ fun testInvalidSelectedProfile() {
+ val intent =
+ Intent(ACTION_VIEW).apply {
+ data = targetUri
+ putExtra(EXTRA_SELECTED_PROFILE, -1000)
+ }
+
+ val activity = createActivityModel(intent)
+
+ val result = readResolverRequest(activity)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<ResolverRequest>
+
+ assertWithMessage("the first finding")
+ .that(result.errors.firstOrNull())
+ .isInstanceOf(UncaughtException::class.java)
+ }
+
+ @Test
+ fun payloadIntents_includesOnlyTarget() {
+ val intent2 = Intent(Intent.ACTION_SEND_MULTIPLE)
+ val intent1 =
+ Intent(Intent.ACTION_SEND).apply {
+ putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2))
+ }
+ val activity = createActivityModel(targetIntent = intent1)
+
+ val result = readResolverRequest(activity)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ResolverRequest>
+
+ // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS
+ // that is only supported for Chooser and should be not be added here.
+ assertThat(result.value.payloadIntents).containsExactly(intent1)
+ }
+
+ @Test
+ fun testAllValues() {
+ val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") }
+ val activity = createActivityModel(targetIntent = intent)
+
+ activity.intent.putExtras(
+ bundleOf(
+ EXTRA_CALLING_USER to UserHandle.of(123),
+ EXTRA_SELECTED_PROFILE to PROFILE_WORK,
+ EXTRA_IS_AUDIO_CAPTURE_DEVICE to true,
+ )
+ )
+
+ val result = readResolverRequest(activity)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<ResolverRequest>
+
+ assertThat(result.value.intent.filterEquals(activity.intent)).isTrue()
+ assertThat(result.value.isAudioCaptureDevice).isTrue()
+ assertThat(result.value.callingUser).isEqualTo(UserHandle.of(123))
+ assertThat(result.value.selectedProfile).isEqualTo(WORK)
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/tests/unit/src/com/android/intentresolver/util/TestExecutor.kt
index 103e8bf4..214b9707 100644
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ b/tests/unit/src/com/android/intentresolver/util/TestExecutor.kt
@@ -14,18 +14,27 @@
* limitations under the License.
*/
-package com.android.intentresolver.contentpreview
+package com.android.intentresolver.util
-import androidx.annotation.MainThread
-import androidx.lifecycle.ViewModel
-import com.android.intentresolver.ChooserRequestParameters
+import java.util.concurrent.Executor
-/** A contract for the preview view model. Added for testing. */
-abstract class BasePreviewViewModel : ViewModel() {
- @MainThread
- abstract fun createOrReuseProvider(
- chooserRequest: ChooserRequestParameters
- ): PreviewDataProvider
+class TestExecutor(private val immediate: Boolean = false) : Executor {
+ private var pendingCommands = ArrayDeque<Runnable>()
- @MainThread abstract fun createOrReuseImageLoader(): ImageLoader
+ val pendingCommandCount: Int
+ get() = pendingCommands.size
+
+ override fun execute(command: Runnable) {
+ if (immediate) {
+ command.run()
+ } else {
+ pendingCommands.add(command)
+ }
+ }
+
+ fun runUntilIdle() {
+ while (pendingCommands.isNotEmpty()) {
+ pendingCommands.removeFirst().run()
+ }
+ }
}
diff --git a/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt b/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt
new file mode 100644
index 00000000..473d9b72
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
+
+import com.android.intentresolver.backgroundDispatcher
+import com.android.systemui.kosmos.Kosmos
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+
+fun Kosmos.runTest(
+ dispatcher: TestDispatcher = StandardTestDispatcher(),
+ block: suspend KosmosTestScope.() -> Unit,
+) {
+ val kosmos = this
+ backgroundDispatcher = dispatcher
+ kotlinx.coroutines.test.runTest(dispatcher) { KosmosTestScope(kosmos, this).block() }
+}
+
+fun runKosmosTest(
+ dispatcher: TestDispatcher = StandardTestDispatcher(),
+ block: suspend KosmosTestScope.() -> Unit,
+) {
+ Kosmos().runTest(dispatcher, block)
+}
+
+class KosmosTestScope(
+ kosmos: Kosmos,
+ private val testScope: TestScope,
+) : Kosmos by kosmos {
+ val backgroundScope
+ get() = testScope.backgroundScope
+
+ @ExperimentalCoroutinesApi fun runCurrent() = testScope.runCurrent()
+}
diff --git a/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt
new file mode 100644
index 00000000..b96b6f05
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
+
+import com.google.common.truth.Correspondence
+import com.google.common.truth.IterableSubject
+
+fun <A, B> IterableSubject.comparingElementsUsingTransform(
+ description: String,
+ function: (A) -> B,
+): IterableSubject.UsingCorrespondence<A, B> =
+ comparingElementsUsing(Correspondence.transforming(function, description))
diff --git a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt
index 18218064..32c19f13 100644
--- a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt
+++ b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.util
import android.app.PendingIntent
diff --git a/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt
new file mode 100644
index 00000000..93a5ec0c
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.validation
+
+import com.android.intentresolver.validation.types.value
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.fail
+import org.junit.Test
+
+class ValidationTest {
+
+ /** Test required values. */
+ @Test
+ fun required_valuePresent() {
+ val result: ValidationResult<String> =
+ validateFrom({ 1 }) {
+ val required: Int = required(value<Int>("key"))
+ "return value: $required"
+ }
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<String>
+
+ assertThat(result.value).isEqualTo("return value: 1")
+ assertThat(result.warnings).isEmpty()
+ }
+
+ /** Test reporting of absent required values. */
+ @Test
+ fun required_valueAbsent() {
+ val result: ValidationResult<String> =
+ validateFrom({ null }) {
+ required(value<Int>("key"))
+ fail("'required' should have thrown an exception")
+ "return value"
+ }
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<String>
+
+ assertThat(result.errors).containsExactly(NoValue("key", Importance.CRITICAL, Int::class))
+ }
+
+ /** Test optional values are ignored when absent. */
+ @Test
+ fun optional_valuePresent() {
+ val result: ValidationResult<String> =
+ validateFrom({ 1 }) {
+ val optional: Int? = optional(value<Int>("key"))
+ "return value: $optional"
+ }
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<String>
+
+ assertThat(result.value).isEqualTo("return value: 1")
+ assertThat(result.warnings).isEmpty()
+ }
+
+ /** Test optional values are ignored when absent. */
+ @Test
+ fun optional_valueAbsent() {
+ val result: ValidationResult<String> =
+ validateFrom({ null }) {
+ val optional: String? = optional(value<String>("key"))
+ "return value: $optional"
+ }
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<String>
+
+ assertThat(result.value).isEqualTo("return value: null")
+ assertThat(result.warnings).isEmpty()
+ }
+
+ /** Test reporting of ignored values. */
+ @Test
+ fun ignored_valuePresent() {
+ val result: ValidationResult<String> =
+ validateFrom(mapOf("key" to 1)::get) {
+ ignored(value<Int>("key"), "no longer supported")
+ "result value"
+ }
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<String>
+
+ assertThat(result.value).isEqualTo("result value")
+ assertThat(result.warnings).containsExactly(IgnoredValue("key", "no longer supported"))
+ }
+
+ /** Test reporting of ignored values. */
+ @Test
+ fun ignored_valueAbsent() {
+ val result: ValidationResult<String> =
+ validateFrom({ null }) {
+ ignored(value<Int>("key"), "ignored when option foo is set")
+ "result value"
+ }
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<String>
+
+ assertThat(result.value).isEqualTo("result value")
+ assertThat(result.warnings).isEmpty()
+ }
+
+ /** Test handling of exceptions in the validation function. */
+ @Test
+ fun thrown_exception() {
+ val result: ValidationResult<String> = validateFrom({ null }) { error("something") }
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<String>
+
+ val errorType = result.errors.map { it::class }.first()
+ assertThat(errorType).isEqualTo(UncaughtException::class)
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt
new file mode 100644
index 00000000..f8622ce0
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.validation.types
+
+import android.content.Intent
+import android.content.Intent.URI_INTENT_SCHEME
+import android.net.Uri
+import androidx.core.net.toUri
+import androidx.test.ext.truth.content.IntentSubject.assertThat
+import com.android.intentresolver.validation.Importance.CRITICAL
+import com.android.intentresolver.validation.Importance.WARNING
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValueIsWrongType
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class IntentOrUriTest {
+
+ /** Test for validation success when the value is an Intent. */
+ @Test
+ fun intent() {
+ val keyValidator = IntentOrUri("key")
+ val values = mapOf("key" to Intent("GO"))
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<Intent>
+ assertThat(result.value).hasAction("GO")
+ }
+
+ /** Test for validation success when the value is a Uri. */
+ @Test
+ fun uri() {
+ val keyValidator = IntentOrUri("key")
+ val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri())
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<Intent>
+ assertThat(result.value).hasAction("GO")
+ }
+
+ /** Test the failure result when the value is missing. */
+ @Test
+ fun missing() {
+ val keyValidator = IntentOrUri("key")
+
+ val result = keyValidator.validate({ null }, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Intent>
+
+ assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class))
+ }
+
+ /** Check validation passes when value is null and importance is [WARNING] (optional). */
+ @Test
+ fun optional() {
+ val keyValidator = ParceledArray("key", Intent::class)
+
+ val result = keyValidator.validate(source = { null }, WARNING)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<List<Intent>>
+ assertThat(result.errors).isEmpty()
+ }
+
+ /**
+ * Test for failure result when the value is neither Intent nor Uri, with importance CRITICAL.
+ */
+ @Test
+ fun wrongType_required() {
+ val keyValidator = IntentOrUri("key")
+ val values = mapOf("key" to 1)
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Intent>
+
+ assertThat(result.errors)
+ .containsExactly(
+ ValueIsWrongType(
+ "key",
+ importance = CRITICAL,
+ actualType = Int::class,
+ allowedTypes = listOf(Intent::class, Uri::class)
+ )
+ )
+ }
+
+ /** Test for warnings when the value is neither Intent nor Uri, with importance WARNING. */
+ @Test
+ fun wrongType_optional() {
+ val keyValidator = IntentOrUri("key")
+ val values = mapOf("key" to 1)
+
+ val result = keyValidator.validate(values::get, WARNING)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Intent>
+
+ assertThat(result.errors)
+ .containsExactly(
+ ValueIsWrongType(
+ "key",
+ importance = WARNING,
+ actualType = Int::class,
+ allowedTypes = listOf(Intent::class, Uri::class)
+ )
+ )
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt
new file mode 100644
index 00000000..5284cbec
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.validation.types
+
+import android.content.Intent
+import android.graphics.Point
+import com.android.intentresolver.validation.Importance.CRITICAL
+import com.android.intentresolver.validation.Importance.WARNING
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValueIsWrongType
+import com.android.intentresolver.validation.WrongElementType
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class ParceledArrayTest {
+
+ /** Check that a array is handled correctly when valid. */
+ @Test
+ fun valid() {
+ val keyValidator = ParceledArray("key", elementType = String::class)
+ val values = mapOf("key" to arrayOf("String"))
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<List<String>>
+ assertThat(result.value).containsExactly("String")
+ }
+
+ /** Check correct failure result when an array has the wrong element type. */
+ @Test
+ fun wrongElementType() {
+ val keyValidator = ParceledArray("key", elementType = Intent::class)
+ val values = mapOf("key" to arrayOf(Point()))
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<List<Intent>>
+
+ assertThat(result.errors)
+ .containsExactly(
+ // TODO: report with a new class `WrongElementType` to improve clarity
+ WrongElementType(
+ "key",
+ importance = CRITICAL,
+ container = Array::class,
+ actualType = Point::class,
+ expectedType = Intent::class
+ )
+ )
+ }
+
+ /** Check correct failure result when an array value is missing. */
+ @Test
+ fun missing() {
+ val keyValidator = ParceledArray("key", Intent::class)
+
+ val result = keyValidator.validate(source = { null }, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<List<Intent>>
+
+ assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class))
+ }
+
+ /** Check validation passes when value is null and importance is [WARNING] (optional). */
+ @Test
+ fun optional() {
+ val keyValidator = ParceledArray("key", Intent::class)
+
+ val result = keyValidator.validate(source = { null }, WARNING)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<List<Intent>>
+
+ assertThat(result.errors).isEmpty()
+ }
+
+ /** Check correct failure result when the array value itself is the wrong type. */
+ @Test
+ fun wrongType() {
+ val keyValidator = ParceledArray("key", Intent::class)
+ val values = mapOf("key" to 1)
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<List<Intent>>
+
+ assertThat(result.errors)
+ .containsExactly(
+ ValueIsWrongType(
+ "key",
+ importance = CRITICAL,
+ actualType = Int::class,
+ allowedTypes = listOf(Intent::class)
+ )
+ )
+ }
+}
diff --git a/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt
new file mode 100644
index 00000000..1b6bace1
--- /dev/null
+++ b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 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.intentresolver.validation.types
+
+import com.android.intentresolver.validation.Importance.CRITICAL
+import com.android.intentresolver.validation.Importance.WARNING
+import com.android.intentresolver.validation.Invalid
+import com.android.intentresolver.validation.NoValue
+import com.android.intentresolver.validation.Valid
+import com.android.intentresolver.validation.ValueIsWrongType
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class SimpleValueTest {
+
+ /** Test for validation success when the value is present and the correct type. */
+ @Test
+ fun present() {
+ val keyValidator = SimpleValue("key", expected = Double::class)
+ val values = mapOf("key" to Math.PI)
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Valid::class.java)
+ result as Valid<Double>
+ assertThat(result.value).isEqualTo(Math.PI)
+ }
+
+ /** Test for validation success when the value is present and the correct type. */
+ @Test
+ fun wrongType() {
+ val keyValidator = SimpleValue("key", expected = Double::class)
+ val values = mapOf("key" to "Apple Pie")
+
+ val result = keyValidator.validate(values::get, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Double>
+ assertThat(result.errors)
+ .containsExactly(
+ ValueIsWrongType(
+ "key",
+ importance = CRITICAL,
+ actualType = String::class,
+ allowedTypes = listOf(Double::class)
+ )
+ )
+ }
+
+ /** Test the failure result when the value is missing. */
+ @Test
+ fun missing() {
+ val keyValidator = SimpleValue("key", expected = Double::class)
+
+ val result = keyValidator.validate(source = { null }, CRITICAL)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Double>
+
+ assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class))
+ }
+
+ /** Test the failure result when the value is missing. */
+ @Test
+ fun optional() {
+ val keyValidator = SimpleValue("key", expected = Double::class)
+
+ val result = keyValidator.validate(source = { null }, WARNING)
+
+ assertThat(result).isInstanceOf(Invalid::class.java)
+ result as Invalid<Double>
+
+ // Note: As single optional validation result, the return must be Invalid
+ // when there is no value to return, but no errors will be reported because
+ // an optional value cannot be "missing".
+ assertThat(result.errors).isEmpty()
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
index 4f4223c0..b1e8593d 100644
--- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
+++ b/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
@@ -18,6 +18,7 @@ package com.android.intentresolver.widget
import android.graphics.Bitmap
import android.net.Uri
+import android.util.Size
import com.android.intentresolver.captureMany
import com.android.intentresolver.mock
import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader
@@ -49,6 +50,7 @@ class BatchPreviewLoaderTest {
private val testScope = CoroutineScope(dispatcher)
private val onCompletion = mock<() -> Unit>()
private val onUpdate = mock<(List<Preview>) -> Unit>()
+ private val previewSize = Size(500, 500)
@Before
fun setup() {
@@ -71,6 +73,7 @@ class BatchPreviewLoaderTest {
BatchPreviewLoader(
imageLoader,
previews(uriOne, uriTwo),
+ previewSize,
totalItemCount = 2,
onUpdate,
onCompletion
@@ -94,6 +97,7 @@ class BatchPreviewLoaderTest {
BatchPreviewLoader(
imageLoader,
previews(uriOne, uriTwo, uriThree),
+ previewSize,
totalItemCount = 3,
onUpdate,
onCompletion
@@ -122,7 +126,14 @@ class BatchPreviewLoaderTest {
}
imageLoader.setUriLoadingOrder(*loadingOrder)
val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion)
+ BatchPreviewLoader(
+ imageLoader,
+ previews(*uris),
+ previewSize,
+ uris.size,
+ onUpdate,
+ onCompletion
+ )
testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
dispatcher.scheduler.advanceUntilIdle()
@@ -151,7 +162,14 @@ class BatchPreviewLoaderTest {
val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) }
imageLoader.setUriLoadingOrder(*loadingOrder)
val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion)
+ BatchPreviewLoader(
+ imageLoader,
+ previews(*uris),
+ previewSize,
+ uris.size,
+ onUpdate,
+ onCompletion
+ )
testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
dispatcher.scheduler.advanceUntilIdle()
@@ -166,7 +184,9 @@ class BatchPreviewLoaderTest {
private fun createUri(idx: Int): Uri = Uri.parse("content://org.pkg.app/image-$idx.png")
private fun fail(uri: Uri) = uri to false
+
private fun succeed(uri: Uri) = uri to true
+
private fun previews(vararg uris: Uri) =
uris
.fold(ArrayList<Preview>(uris.size)) { acc, uri ->
@@ -175,7 +195,7 @@ class BatchPreviewLoaderTest {
.asFlow()
}
-private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? {
+private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Size, Boolean) -> Bitmap? {
private val loadingOrder = ArrayDeque<Pair<Uri, Boolean>>()
private val pendingRequests = LinkedHashMap<Uri, CompletableDeferred<Bitmap?>>()
private val flow = MutableSharedFlow<Unit>(replay = 1)
@@ -203,7 +223,7 @@ private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) ->
loadingOrder.addAll(uris)
}
- override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? {
+ override suspend fun invoke(uri: Uri, size: Size, cache: Boolean): Bitmap? {
val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() }
flow.tryEmit(Unit)
return deferred.await()